Compare commits

...
Author SHA1 Message Date
Will LinandClaude Fable 5 aa95a4c18e [misc]: add CPU regression tests for the on-device uint8 frame path
Two tests certifying #1362's frame semantics without a GPU:

- frames_match_legacy_cpu_loop: the quantize-then-grid path reproduces
  the legacy make_grid -> *255 -> uint8 per-frame loop bit-exactly for
  in-range fp32 pixels (batch>1 nrow=6 grid layout, odd frame count,
  uint8 HWC contract). Verified to pass against main's legacy loop too,
  so it pins both sides of the equivalence.
- frames_clamp_out_of_range_pixels: out-of-[0,1] VAE output saturates
  at 0/255 instead of wrapping mod 256; fails on the pre-#1362 loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 02:39:09 +00:00
Will LinandClaude Fable 5 f56f567042 [misc]: correct the post-decode rationale comments
The pre-#1362 `samples.copy_(output_batch.output)` never passed
`non_blocking=True` (git log -S confirms), so drop the deferred
non-blocking-transfer claim and describe the measured costs instead:
a full fp32 D->H copy plus a single-threaded per-frame CPU loop. Also
drop the reintroduced hardcoded "~50 MB" size estimate (same class of
comment Copilot flagged and commit 0399713e7 removed elsewhere) and
scope the "typical flow" claim to the CLI, since the SamplingParam
API default is return_frames=True.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 02:34:43 +00:00
Will LinandClaude Fable 5 15a164a052 [bugfix]: derive result size from the decoded output when the samples mirror is skipped
PR #1362 commit 3 leaves `samples` as an empty placeholder when
`return_frames=False`, but main picked up #1595's refiner size
reporting in the meantime, and `_resolve_output_size(samples, ...)`
silently fell back to the requested geometry in exactly the common
save flow the PR optimizes. Read the geometry from
`output_batch.output` instead (shape-only access, no D->H copy),
gated on `needs_frame_output` so metadata-only and audio-only calls
still never inspect the (possibly dropped) worker output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 02:34:16 +00:00
Will Lin aaaa7a14a3 Merge remote-tracking branch 'origin/main' into pr1362-fixes 2026-08-21 02:21:44 +00:00
Aryan Kumar 86d639c848 [docs] Announce FastMetal-QAD (#1721) 2026-08-19 13:45:49 -07:00
00338aa9ca [perf] Add FA4 CuTe backward support for VSA-256 (#1639)
Co-authored-by: Hyunsung Lee <hyunsungl@sizigistudios.com>
Co-authored-by: alexzms <3036648523@qq.com>
2026-08-19 11:46:49 -07:00
Aryan KumarandAryan Kumar 8537dcd6de [feat]: Apple Silicon MLX runtime — INT8 Wan2.1 and Wan2.2 inference (#1638)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-08-18 15:08:00 -07:00
RaghavandClaude Opus 4.7 3c3da4d057 [perf]: skip the fp32 samples D->H copy when return_frames=False
The previous commit rebuilt the post-decode frames path to read
`output_batch.output` directly via the GPU `vid_u8` cast, so `samples`
is now consumed in exactly one place — the result dict's `samples`
field, gated on `batch.return_frames`. When the caller doesn't ask
for `samples`, the pinned ~50 MB fp32 alloc and its D->H copy (and
the latent fallthrough `.cpu()`) are dead weight; the typical
generate flow (save_video=True, return_frames=False) hits this on
every call.

Extend `skip_pixel_prealloc` to include `not return_frames` so the
pinned buffer is allocated only when needed, and short-circuit the
copy/`.cpu()` on the same condition. No effect when
`return_frames=True` or for latent callers that read `samples`;
correctness is unchanged (SSIM-gated, same as the parent commit).

Removes the residual ~2 s the PR text already calls out for the
"only saving to disk" case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-16 03:02:49 -07:00
Raghav 0399713e7b [perf]: clarify samples/output_batch.output equivalence; drop hardcoded transfer size
Address review comments on #1362:
- Document that `samples` is just the pinned-CPU mirror of
  `output_batch.output` with no intervening preprocessing, so sourcing
  from `output_batch.output` is the same data (Copilot).
- Replace the hardcoded ~0.3-1.4 GB estimate with a description of the
  scaling relationship; it varies with resolution/frames/batch/dtype
  and would rot (Copilot).
- Note the SSIM-gated (not bit-exact) equivalence inline.

No behavior change.
2026-07-16 02:59:59 -07:00
Raghav 0af2e9e8ef [perf]: quantize frames to uint8 on-device before the D->H copy
PostDecodeFrameProcessStage was charged ~6s/run (~25% of e2e on Cosmos
2.5, scaling with frames/resolution). Profiling (nsys + microbench)
showed the stage's own compute is only ~0.5-1.9s; the rest is the
non-blocking pinned-CPU samples.copy_(output) D->H of the full fp32
video (~0.3-1.4 GB) completing lazily and blocking the first
postprocess op, plus a single-threaded per-frame CPU *255/cast loop.

Cast to uint8 on the source device (typically CUDA) before the copy:
the transfer becomes 4x smaller (fp32 -> uint8) and the elementwise
work runs on the GPU. Microbench: 2.056s -> 0.034s (T=29), 2.304s ->
0.132s (T=125), 17-60x on the measurable cost.

clamp_(0, 255) additionally fixes a latent overflow: VAE output
slightly outside [0, 1] previously wrapped mod 256 in the unclamped
(x * 255).to(uint8) cast.

Output is not bit-identical to the old CPU cast (float->uint8 differs
<=1 LSB between CPU and GPU on boundary pixels); gate via SSIM rather
than exact equality. Scoped to the pixel-video path only; latent,
audio-only, and return-samples paths are unchanged.
2026-07-16 02:59:59 -07:00
92 changed files with 14250 additions and 163 deletions
+149
View File
@@ -0,0 +1,149 @@
name: macOS MLX Smoke
on:
pull_request:
branches: [main]
paths:
- ".github/workflows/ci-macos-mlx.yml"
- "fastvideo/mlx_runtime/**"
- "fastvideo/tests/mlx/**"
- "fastvideo/tests/platforms/test_mps_vsa_error.py"
- "fastvideo/platforms/mps.py"
- "fastvideo/platforms/__init__.py"
- "fastvideo/__init__.py"
- "examples/inference/basic/mlx_*.py"
- "fastvideo/benchmarks/mlx_*.py"
- "pyproject.toml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: macos-mlx-${{ github.ref }}
cancel-in-progress: true
jobs:
mlx-smoke:
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.draft != true
runs-on: macos-15
timeout-minutes: 25
env:
FASTVIDEO_ATTENTION_BACKEND: TORCH_SDPA
TOKENIZERS_PARALLELISM: "false"
MASTER_ADDR: localhost
MASTER_PORT: "29513"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- uses: astral-sh/setup-uv@v3
- name: Install lightweight MLX smoke dependencies
run: |
uv pip install --system \
--index-url https://download.pytorch.org/whl/cpu \
torch==2.11.0 torchvision torchaudio
uv pip install --system \
pytest numpy scipy pillow imageio einops cloudpickle filelock \
PyYAML diffusers huggingface_hub remote-pdb safetensors loguru mlx \
"ftfy>=6.3.1" "opencv-python>=4.10.0.84" psutil "transformers>=5.0.0"
- name: Show Apple runtime
run: |
python - <<'PY'
import platform
import mlx.core as mx
import torch
print("machine:", platform.machine())
print("processor:", platform.processor())
print("mlx default device:", mx.default_device())
memory_size = mx.metal.device_info().get("memory_size") if mx.metal.is_available() else "metal unavailable"
print("mlx memory_size:", memory_size)
print("torch:", torch.__version__)
print("torch mps available:", torch.backends.mps.is_available())
PY
- name: Run MLX smoke tests
run: |
python -m pytest \
fastvideo/tests/mlx/test_dmd_sampling.py \
fastvideo/tests/mlx/test_memory_limits.py \
fastvideo/tests/mlx/test_quant_capability.py \
fastvideo/tests/mlx/test_mlx_dit_parity.py \
fastvideo/tests/mlx/test_mlx_compile_parity.py \
fastvideo/tests/mlx/test_mlx_checkpoint.py \
fastvideo/tests/mlx/test_mlx_fastwan_benchmark.py \
fastvideo/tests/mlx/test_taehv_decode.py \
fastvideo/tests/mlx/test_frame_upsample.py \
fastvideo/tests/mlx/test_mlx_fast_spatial.py \
fastvideo/tests/mlx/test_mlx_refine.py \
fastvideo/tests/mlx/test_mlx_prompt_to_video_decode.py \
fastvideo/tests/mlx/test_mlx_wan22_prompt_cache_fingerprint.py \
fastvideo/tests/mlx/test_wan22_sample.py \
fastvideo/tests/mlx/test_windowed_attention.py \
fastvideo/tests/mlx/test_mlx_rife_interpolation.py::test_rife_download_unavailable_has_specific_error \
fastvideo/tests/mlx/test_mlx_rife_interpolation.py::test_rife_backend_regression_is_not_skip_eligible \
fastvideo/tests/platforms/test_mps_vsa_error.py \
-q
# Same tests on MLX's CPU backend. Hosted macOS runners are scarce and
# slower to schedule; this Linux job gives fast PR signal on the identical
# graph (the parity tests were designed to be backend-agnostic), while the
# macOS job above stays the source of truth for Metal behavior.
mlx-smoke-linux-cpu:
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.draft != true
runs-on: ubuntu-latest
timeout-minutes: 20
env:
FASTVIDEO_ATTENTION_BACKEND: TORCH_SDPA
TOKENIZERS_PARALLELISM: "false"
MASTER_ADDR: localhost
MASTER_PORT: "29513"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- uses: astral-sh/setup-uv@v3
- name: Install lightweight MLX smoke dependencies (CPU backend)
run: |
uv pip install --system \
--index-url https://download.pytorch.org/whl/cpu \
torch==2.11.0 torchvision torchaudio
uv pip install --system \
pytest numpy scipy pillow imageio einops cloudpickle filelock \
PyYAML diffusers huggingface_hub remote-pdb safetensors loguru "mlx[cpu]" \
"ftfy>=6.3.1" "opencv-python>=4.10.0.84" psutil "transformers>=5.0.0"
- name: Run MLX smoke tests (CPU backend)
run: |
python -m pytest \
fastvideo/tests/mlx/test_dmd_sampling.py \
fastvideo/tests/mlx/test_memory_limits.py \
fastvideo/tests/mlx/test_quant_capability.py \
fastvideo/tests/mlx/test_mlx_dit_parity.py \
fastvideo/tests/mlx/test_mlx_compile_parity.py \
fastvideo/tests/mlx/test_mlx_checkpoint.py \
fastvideo/tests/mlx/test_mlx_fastwan_benchmark.py \
fastvideo/tests/mlx/test_taehv_decode.py \
fastvideo/tests/mlx/test_frame_upsample.py \
fastvideo/tests/mlx/test_mlx_fast_spatial.py \
fastvideo/tests/mlx/test_mlx_refine.py \
fastvideo/tests/mlx/test_mlx_prompt_to_video_decode.py \
fastvideo/tests/mlx/test_mlx_wan22_prompt_cache_fingerprint.py \
fastvideo/tests/mlx/test_wan22_sample.py \
fastvideo/tests/mlx/test_windowed_attention.py \
fastvideo/tests/mlx/test_mlx_rife_interpolation.py::test_rife_download_unavailable_has_specific_error \
fastvideo/tests/mlx/test_mlx_rife_interpolation.py::test_rife_backend_regression_is_not_skip_eligible \
fastvideo/tests/platforms/test_mps_vsa_error.py \
-q
+6
View File
@@ -9,6 +9,7 @@
**FastVideo is a unified post-training and real-time inference framework for accelerated video generation.**
## NEWS
- `2026/08/19`: FastVideo now supports MLX on Apple Silicon with [FastMetal-QAD](https://huggingface.co/collections/FastVideo/fastmetal), a family of 1.3B, 5B, and 14B models optimized for Mac—follow the [Apple Silicon guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mps/) and read the [Blog](https://haoailab.com/blogs/fastmetal/).
- `2026/06/23`: Release FastWan-QAD: 5s of Video generated in 1.8s E2E. See the [FastWan-QAD models](https://huggingface.co/FastVideo/FastWan-QAD-FP8-1.3B), [Attn-QAT training guide](https://haoailab.com/FastVideo/training/attn_qat/), and [blog](https://haoailab.com/blogs/fastwan-qad/).
- `2026/03/17`: Release demo: Into the Dreamverse: Vibe Directing in FastVideo, check out the [Blog](https://haoailab.com/blogs/dreamverse/).
- `2026/03/13`: Release demo: Create a 5s 1080p Video in 4.5s with FastVideo on a Single GPU, check out the [Blog](https://haoailab.com/blogs/fastvideo_realtime_1080p/).
@@ -62,6 +63,11 @@ UV_TORCH_BACKEND=cu126 uv pip install fastvideo
Use `UV_TORCH_BACKEND=cu130` on CUDA 13. Apple silicon users should follow the
[MPS installation guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mps/).
> **On an Apple Silicon Mac?** FastVideo runs FastWan text-to-video natively
> through an MLX runtime — a 5-second 480p clip generated locally, no cloud,
> no discrete GPU. Install with `uv pip install -e '.[mlx]'` and follow the
> [Apple Silicon guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mps/).
Please see our [docs](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/) for more detailed installation instructions.
> **On an NVIDIA DGX Spark (GB10 / ARM64 + CUDA 13)?** There's no prebuilt ARM wheel for the FastVideo CUDA kernel, so it's an editable from-source install (`UV_TORCH_BACKEND=cu130 uv pip install -e .`, which compiles that kernel for you) rather than `UV_TORCH_BACKEND=cu130 uv pip install fastvideo`. A compatible prebuilt ARM64 FlashAttention wheel is available separately. Follow the [DGX Spark install guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/spark/).
+1 -1
View File
@@ -68,7 +68,7 @@ ARG FLASH_ATTN_WHEEL_RELEASE_ARM64=https://github.com/mjun0812/flash-attention-p
# cutlass-4.4 `cute.core.ThrMma` API, which crashes on the cutlass-dsl 4.5 that
# flashinfer/quack pull in. After the wheel install we overlay this cutlass-4.5-safe
# upstream cute (flash-attn-4) so the image runs FA4 instead of the FA2 fallback.
ARG FA4_CUTE_REF=82d6441eec5d4dfec120153db2c0145ae855a083
ARG FA4_CUTE_REF=14c377950125c70b7a9dabf9c561fca53715ac7d
# Provided automatically by BuildKit/buildx (e.g. "amd64" / "arm64") and used to
# select the prebuilt flash-attn wheel. Empty under a plain `docker build` without
+128
View File
@@ -0,0 +1,128 @@
# Fast mode (RIFE) — Apple Silicon
`--fast` makes local generation ~2.7× faster by **generating fewer frames and
interpolating the rest** with an Apple-Silicon-native RIFE model, instead of
denoising every frame. Video-diffusion denoise is dominated by self-attention,
which is O(tokens²); halving the frames cuts the token count ~2× and the denoise
compute ~3.7×, so the wall-clock drops far more than 2×. RIFE (which estimates
its own optical flow — no motion vectors needed) fills the dropped frames back
in for ~1.4 s, and a light unsharp pass counters its softening.
Measured on the 1.3B INT8 QAD model (fox, 480×832×81, M4): generate 41 + RIFE→81
runs in ~35 s of denoise vs ~90 s full, at reconstruction MS-SSIM **0.97**.
Reproduce with `python -m fastvideo.benchmarks.eval_metalfx_rife --mode int8`.
> **Note:** Apple's *MetalFX* frame interpolation is **not** usable here — it
> requires game-engine motion vectors + depth, which diffusion output lacks. We
> use the video-native **`rife-mlx`** model instead (Metal-backed, torch-free).
## Install
```bash
uv pip install -e ".[mlx]" # RIFE ships vendored; this only needs MLX
```
## Use
```bash
python examples/inference/basic/mlx_wan_prompt_to_video.py \
--mlx-checkpoint <FastWan2.1-T2V-1.3B-INT8-QAD> \
--prompt "A red fox trotting through a snowy pine forest at golden hour, cinematic" \
--num-frames 81 --fast \
--output-path video_samples/fox_fast.mp4
```
`--num-frames` stays the *target* length; fast mode generates the smallest
VAE-aligned keyframe count that RIFE can interpolate to that target.
| Flag | Default | Meaning |
|---|---|---|
| `--fast` / `--no-fast` | off | enable fast mode |
| `--fast-factor` | 2 | generate 1/factor of the frames (2 = half) |
| `--fast-sharpen` | 0.6 | light unsharp strength to counter RIFE softness (0 disables) |
Fast mode composes with everything else (`--mlx-quantization int8`,
`--mlx-compile`, TAEHV vs `--decode-backend wan-vae`). Keep `--fast-factor` at 2
for quality — larger temporal gaps are where RIFE starts inventing motion.
## Spatial fast mode (`--fast-spatial`)
The spatial twin of `--fast`: instead of dropping frames, drop pixels. Denoise
*and decode* at `height/width // fast-spatial-scale`, then resample the decoded
frames up to the requested size. Self-attention is O(tokens²), so halving each
spatial axis cuts the token count 4× and the denoise time far more than that —
measured on the 1.3B INT8 QAD model at 480×832×81, M4 Max: **86.1 s → 10.3 s**
of denoise. It composes with `--fast`; both together run the same clip in
**4.5 s** of denoise.
```bash
python examples/inference/basic/mlx_wan_prompt_to_video.py \
--prompt "A red fox trotting through a snowy pine forest at golden hour, cinematic" \
--height 480 --width 832 --num-frames 81 --fast-spatial \
--output-path video_samples/fox_fast_spatial.mp4
```
| Flag | Default | Meaning |
|---|---|---|
| `--fast-spatial` / `--no-fast-spatial` | off | enable spatial fast mode |
| `--fast-spatial-scale` | 2 | denoise at 1/scale of each spatial axis |
| `--fast-spatial-upsample-mode` | `lanczos` | pixel interpolation kernel (`lanczos`, `cubic`, `bilinear`, `nearest`) |
| `--fast-spatial-sharpen` | 0.4 | light unsharp strength to counter resampling softness (0 disables) |
### The upsample must happen in pixel space
This is the one thing to get right. The obvious implementation — bilinearly
upsample the finished latents and decode at the target size — **does not work**,
and produces a distinctive failure: correct composition and silhouette under a
smeared, hazy veil, with ringing along strong edges.
A Wan latent cell is a *learned code* for an 8×8 (Wan2.1) or 16×16 (Wan2.2)
pixel block, not a low-pass sample of the image. The average of two adjacent
codes is not the code of the averaged blocks; it is a vector the decoder was
never trained on. Measured on Wan2.1-1.3B at 480×832, a 2× bilinear latent
upsample destroys **62%** of the latent's high-frequency energy while leaving
its overall magnitude intact — exactly the signature of that veil. At Wan2.2-5B
the same operation degrades to black or noise.
Decoded RGB frames have no such problem: an image *is* a sampled 2-D signal, so
Lanczos interpolation is the operation it was defined for. The result is soft —
it carries stage-1's real detail budget and no more — but clean and coherent.
`--refine` gets away with a latent-space upsample only because a second DMD pass
re-denoises the hand-off; spatial fast mode passes the latent straight to the
decoder, so it cannot.
## Refine (`--refine`) stage-2 timesteps
`--refine` hands stage 1 to stage 2 as `(1 - sigma) * upsampled + sigma * noise`,
where `sigma` comes from the *first* stage-2 timestep. FastWan's DMD grid opens
at `t=1000`, which is `sigma == 1` exactly — so a stage-2 grid that starts there
weights the stage-1 result at zero and refine silently degrades into a plain
full-resolution run at twice the cost.
Left unset, `--refine-dmd-denoising-steps` now derives the stage-2 grid from the
stage-1 one with leading full-noise steps dropped (`1000,757,522` → `757,522`).
That keeps the pass on timesteps the distilled student was trained on while
letting stage-1 structure through: hand-off `sigma = 0.757`, stage-1 weight
`0.243`. Passing a grid that starts at full noise is now an error rather than a
silently wasted pass.
The run prints the resolved hand-off so it is visible:
```
[refine] stage-2 hand-off sigma=0.7568 (stage-1 weight 0.2432)
```
There is a trade-off in choosing that grid. Later start = more of the draft
survives, but fewer stage-2 steps. On Wan2.1 the default `757,522` gives weight
0.243 with two steps; `--refine-dmd-denoising-steps 522` gives weight 0.478 with
one. `--refine-sigma` decouples the noise level from the timestep entirely — it
logs a warning, because the DiT is then told a timestep that does not match the
noise it receives.
**Wan2.2-5B has a lower ceiling.** Its warped schedule maps `1000,757,522` to
sigmas `1.000, 0.940, 0.845`, so the best available stage-1 weight is **0.060**
(vs 0.243 at 1.3B). Un-warped (`--no-warp`) the same grid gives `1.000, 0.757,
0.522` and a weight of 0.243 — but warping is what matches the FastVideo
sampling schedule, so turning it off changes the timesteps the distilled student
sees. Which is better at 5B is unresolved and needs a run on real 5B weights.
+6 -4
View File
@@ -49,10 +49,12 @@ brew install ffmpeg
### Installation
FastWan's native Apple Silicon runtime requires the `mlx` extra.
#### With uv (recommended)
```bash
uv pip install fastvideo
uv pip install "fastvideo[mlx]"
```
#### With Conda environment (alternative)
@@ -60,7 +62,7 @@ uv pip install fastvideo
`uv` works inside an active conda env too, so prefer `uv pip` for the actual install:
```bash
uv pip install fastvideo
uv pip install "fastvideo[mlx]"
```
### Installation from Source
@@ -76,13 +78,13 @@ git clone https://github.com/hao-ai-lab/FastVideo.git && cd FastVideo
Basic installation:
```bash
uv pip install -e .
uv pip install -e ".[mlx]"
```
Alternative with Conda environment:
```bash
uv pip install -e .
uv pip install -e ".[mlx]"
```
## Development Environment Setup
+11
View File
@@ -178,6 +178,17 @@ optimizations: absence means **untested**, not incompatible.
| Matrix Game 3.0 Base Distilled | `FastVideo/Matrix-Game-3.0-Base-Distilled-Diffusers` | 720x1280 | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ |
| GEN3C Cosmos 7B | `FastVideo/GEN3C-Cosmos-7B-Diffusers` | 704px1280p | ❌ | ❌ | ❌ | ⭕ | ⭕ |
## Apple Silicon native runtime
| Release path | Model | Mode | Validated hardware | Status |
| --- | --- | --- | --- | --- |
| MLX FastWan T2V | FastWan-QAD-INT8-1.3B `[release model ID pending]` | 480x832, 81 frames, 3-step DMD, INT8 DiT + TAEHV decode | Apple M4 Max, 36 GB unified-memory class, MLX 0.31.2 | Release candidate; requires release-owner visual sign-off |
This is a text-to-video-only source-install release. It is validated on the
hardware listed above; MLX allocator caps are not evidence of support for a
physical 16 GB Mac. See [Apple Silicon FastWan](../getting_started/installation/mps.md)
for the supported command and release gates.
**Note**: Wan2.2 TI2V 5B has some quality issues when performing I2V generation. We are working on fixing this issue.
***Lucy Edit Dev uses a non-commercial model license. FastVideo support is
@@ -0,0 +1,46 @@
# SPDX-License-Identifier: Apache-2.0
"""Tiny MLX RIFE frame-interpolation smoke test."""
from __future__ import annotations
import argparse
import time
import numpy as np
from fastvideo.mlx_runtime.rife_interp import interpolate, load_model
def main() -> None:
parser = argparse.ArgumentParser(
description="MLX RIFE 4.25 frame interpolation smoke test."
)
parser.add_argument(
"--self-test",
action="store_true",
help="Run a tiny two-frame interpolation test.",
)
args = parser.parse_args()
if not args.self_test:
raise SystemExit("Nothing to do; pass --self-test")
frame0 = np.zeros((64, 96, 3), dtype=np.uint8)
frame1 = np.zeros((64, 96, 3), dtype=np.uint8)
frame1[:, :, 0] = 255
start = time.perf_counter()
model = load_model()
load_s = time.perf_counter() - start
start = time.perf_counter()
frames = interpolate([frame0, frame1], factor=2, model=model)
interp_s = time.perf_counter() - start
assert len(frames) == 3
assert frames[1].shape == frame0.shape
assert frames[1].dtype == np.uint8
print(
"MLX RIFE self-test passed: "
f"load_s={load_s:.3f} interp_s={interp_s:.3f} shape={frames[1].shape}"
)
if __name__ == "__main__":
main()
@@ -0,0 +1,510 @@
# SPDX-License-Identifier: Apache-2.0
"""End-to-end Wan2.2-TI2V-5B generation on Apple Silicon (MLX DiT + MLX TAEHV).
Pipeline: torch/MPS UMT5 encode (shared with 1.3B) → MLXWan22DiT 3-step DMD
(warped schedule, flow_shift=5) → MLX TAEHV decode (taew2_2.pth). Fully MLX
on the heavy DiT + decode path.
PYTHONPATH=$PWD python examples/inference/basic/mlx_wan22_generate.py \
--prompt "A red fox trotting through a snowy pine forest at golden hour" \
--output-path video_samples/demo_5b/fox_5b_mlx.mp4
Decoder backends: ``taehv`` (default, MLX, ~seconds), ``taehv-torch`` (parity),
``wan-vae`` (full AutoencoderKLWan on MPS, slow).
"""
from __future__ import annotations
import argparse
import json
import time
from pathlib import Path
import numpy as np
from fastvideo.mlx_runtime.fast_spatial import DEFAULT_FAST_SPATIAL_SHARPEN
from fastvideo.mlx_runtime.frame_upsample import DEFAULT_PIXEL_UPSAMPLE_MODE, PIXEL_UPSAMPLE_MODES
from fastvideo.mlx_runtime.memory import cleanup_mlx
from fastvideo.mlx_runtime.prompt_cache import (
fingerprint_digest,
load_prompt_cache,
save_prompt_cache,
text_encoder_fingerprint,
)
from fastvideo.mlx_runtime.rife_interp import aligned_keyframe_count
FASTWAN21_MODEL_ID = "FastVideo/FastWan2.1-T2V-1.3B-Diffusers"
FASTWAN22_MODEL_ID = "FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers"
DEFAULT_HEIGHT = 448
DEFAULT_WIDTH = 832
DEFAULT_NUM_FRAMES = 121
def _resolve_model_paths(
*,
text_encoder_root: Path | None,
dit_checkpoint: Path | None,
dit_config: Path | None,
vae_root: Path | None,
mlx_checkpoint: Path | None,
decode_backend: str,
) -> tuple[Path, Path | None, Path | None, Path | None]:
"""Download only the missing assets required by the selected Wan2.2 path."""
from huggingface_hub import snapshot_download
if text_encoder_root is None:
text_encoder_root = Path(snapshot_download(
FASTWAN21_MODEL_ID,
allow_patterns=["tokenizer/*", "text_encoder/*"],
))
if mlx_checkpoint is None and (dit_checkpoint is None or dit_config is None):
patterns = []
if dit_checkpoint is None:
patterns.append("transformer/diffusion_pytorch_model.safetensors")
if dit_config is None:
patterns.append("transformer/config.json")
model_root = Path(snapshot_download(FASTWAN22_MODEL_ID, allow_patterns=patterns))
dit_checkpoint = dit_checkpoint or model_root / "transformer/diffusion_pytorch_model.safetensors"
dit_config = dit_config or model_root / "transformer/config.json"
if decode_backend == "wan-vae" and vae_root is None:
model_root = Path(snapshot_download(FASTWAN22_MODEL_ID, allow_patterns=["vae/*"]))
vae_root = model_root / "vae"
return text_encoder_root, dit_checkpoint, dit_config, vae_root
def _prompt_cache_fingerprint(
*,
prompt: str,
prompt_used: str,
enhance_prompt: bool,
enhance_prompt_backend: str,
text_encoder_root: Path,
max_sequence_length: int,
dtype: str,
) -> dict[str, object]:
return {
"prompt": prompt,
"prompt_used": prompt_used,
"enhance_prompt": enhance_prompt,
"enhance_prompt_backend": enhance_prompt_backend,
"text_encoder": text_encoder_fingerprint(text_encoder_root),
"max_sequence_length": max_sequence_length,
"dtype": dtype,
}
def _default_prompt_cache_path(fingerprint: dict[str, object]) -> Path:
"""Content-addressed default cache file for a prompt fingerprint.
The Wan2.1 entrypoint caches prompt embeddings by default; this one only
did so when handed an explicit ``--prompt-embeds-cache`` path, so every 5B
run paid a full UMT5 encode (~45s on an M4 Max) even for a repeat prompt.
The fingerprint already covers everything that changes the embedding, so
hash it for the filename.
"""
digest = fingerprint_digest(fingerprint)[:32]
return Path.home() / ".cache" / "fastvideo" / "prompt_embeds" / f"wan22_{digest}.npy"
def main() -> None:
parser = argparse.ArgumentParser(
description="MLX Wan2.2-5B T2V (encode → DiT DMD → TAEHV/VAE decode)"
)
parser.add_argument(
"--prompt",
default="A red fox trotting through a snowy pine forest at golden hour, cinematic",
)
parser.add_argument(
"--output-path",
type=Path,
default=Path("video_samples/demo_5b/fox_5b_mlx.mp4"),
)
parser.add_argument(
"--text-encoder-root",
type=Path,
default=None,
help="Root with text_encoder/ + tokenizer/",
)
parser.add_argument(
"--prompt-embeds-cache",
type=Path,
default=None,
help="Explicit .npy UMT5 embedding cache file. Overrides the automatic "
"content-addressed cache (--prompt-cache).",
)
parser.add_argument(
"--prompt-cache",
action=argparse.BooleanOptionalAction,
default=True,
help="Cache prompt embeddings under ~/.cache/fastvideo/prompt_embeds so "
"repeat runs skip the text encoder entirely. Default: on.",
)
parser.add_argument(
"--text-encoder-device",
choices=("auto", "cpu", "mps"),
default="cpu",
help="Device for UMT5 encoding. CPU is safest beside the 5B MLX DiT.",
)
parser.add_argument(
"--enhance-prompt",
action="store_true",
help="Apply deterministic local cinematic prompt enrichment before UMT5.",
)
parser.add_argument(
"--enhance-prompt-backend",
choices=("template",),
default="template",
help="Prompt enrichment backend.",
)
parser.add_argument(
"--dit-checkpoint",
type=Path,
default=None,
)
parser.add_argument("--dit-config", type=Path, default=None)
parser.add_argument(
"--mlx-checkpoint",
type=Path,
default=None,
help="Pre-quantized MLX DiT checkpoint directory. Rewrapped with Wan2.2 per-token conditioning.",
)
parser.add_argument("--vae-root", type=Path, default=None)
parser.add_argument("--height", type=int, default=DEFAULT_HEIGHT)
parser.add_argument("--width", type=int, default=DEFAULT_WIDTH)
parser.add_argument(
"--num-frames",
type=int,
default=DEFAULT_NUM_FRAMES,
help="Pixel frames (121 at 24fps = 5.04 seconds)",
)
parser.add_argument("--seed", type=int, default=1234)
parser.add_argument("--renoise-seed", type=int, default=0)
parser.add_argument("--fps", type=int, default=24)
parser.add_argument("--flow-shift", type=float, default=5.0)
parser.add_argument("--dmd-denoising-steps", default="1000,757,522")
parser.add_argument(
"--no-warp",
action="store_true",
help="Disable schedule warping (debug only).",
)
parser.add_argument(
"--fast",
action="store_true",
help="Generate fewer frames then RIFE-interpolate to --num-frames.",
)
parser.add_argument("--fast-factor", type=int, default=2)
parser.add_argument("--fast-sharpen", type=float, default=0.6)
parser.add_argument(
"--fast-spatial",
action="store_true",
help="Denoise and decode at reduced spatial resolution, then resample "
"the decoded frames up to the target size.",
)
parser.add_argument("--fast-spatial-scale", type=int, default=2)
parser.add_argument(
"--fast-spatial-upsample-mode",
choices=PIXEL_UPSAMPLE_MODES,
default=DEFAULT_PIXEL_UPSAMPLE_MODE,
)
parser.add_argument("--fast-spatial-sharpen", type=float, default=DEFAULT_FAST_SPATIAL_SHARPEN)
parser.add_argument(
"--refine",
action="store_true",
help="Two-pass DMD: coarse denoise, upsample/re-noise, full-res denoise.",
)
parser.add_argument("--refine-scale", type=int, default=2)
parser.add_argument(
"--refine-upsample-mode",
choices=("bilinear", "nearest"),
default="bilinear",
)
parser.add_argument("--no-refine-add-noise", action="store_true")
parser.add_argument(
"--decode-backend",
choices=("taehv", "taehv-torch", "wan-vae"),
default="taehv",
)
parser.add_argument("--save-latents", type=Path, default=None)
parser.add_argument("--metrics-json", type=Path, default=None,
help="Write measured run metadata as JSON for reports or galleries.")
parser.add_argument(
"--compile",
action="store_true",
help="Compile the DiT forward with mx.compile; fallback to eager on failure.",
)
args = parser.parse_args()
if args.fast_factor < 2:
parser.error("--fast-factor must be at least 2")
# --fast-spatial used to be rejected here because it upsampled the completed
# 48-channel latent, which is out of distribution for the decoder and gave
# black or noisy video. The upsample now runs on decoded frames, so the
# latent never leaves the grid it was denoised on and the mode is usable.
if args.refine and args.fast_spatial:
print("[wan22] --refine takes precedence over --fast-spatial")
args.text_encoder_root, args.dit_checkpoint, args.dit_config, args.vae_root = _resolve_model_paths(
text_encoder_root=args.text_encoder_root,
dit_checkpoint=args.dit_checkpoint,
dit_config=args.dit_config,
vae_root=args.vae_root,
mlx_checkpoint=args.mlx_checkpoint,
decode_backend=args.decode_backend,
)
target_frames = args.num_frames
if args.fast:
args.num_frames = aligned_keyframe_count(target_frames, args.fast_factor)
print(
f"[wan22 fast] generating {args.num_frames} frames, "
f"RIFE {args.fast_factor}x -> {target_frames}"
)
import mlx.core as mx
import torch
from examples.inference.basic.mlx_wan_prompt_to_video import (
_postprocess_video,
encode_prompt,
make_rotary_embeddings,
)
from fastvideo.mlx_runtime.fast_spatial import plan_fast_spatial
from fastvideo.mlx_runtime.refine import (
default_refine_timesteps,
plan_refine_resolutions,
prepare_refine_latents,
)
from fastvideo.mlx_runtime.wan22 import (
mlx_wan22_dit_from_diffusers_safetensors,
mlx_wan22_dit_from_mlx_checkpoint,
)
from fastvideo.mlx_runtime.wan22_sample import build_wan22_dmd_schedule, sample_wan22_dmd
from fastvideo.mlx_runtime.wan_vae import decode_latents_to_video
if args.mlx_checkpoint is not None:
config = json.loads((args.mlx_checkpoint / "mlx_dit.json").read_text())["config"]
else:
config = json.loads(args.dit_config.read_text())
patch_size = tuple(config.get("patch_size", (1, 2, 2)))
if args.refine:
active_plan = plan_refine_resolutions(
height=args.height, width=args.width, num_frames=args.num_frames,
spatial_scale=args.refine_scale, vae_spatial_compression=16,
vae_temporal_compression=4, patch_size=patch_size, enabled=True,
)
spatial_mode = "refine"
elif args.fast_spatial:
fast_spatial_plan = plan_fast_spatial(
height=args.height, width=args.width, num_frames=args.num_frames,
spatial_scale=args.fast_spatial_scale, vae_spatial_compression=16,
vae_temporal_compression=4, patch_size=patch_size,
upsample_mode=args.fast_spatial_upsample_mode,
sharpen=args.fast_spatial_sharpen, enabled=True,
)
active_plan = fast_spatial_plan.plan
spatial_mode = "fast_spatial"
else:
active_plan = plan_refine_resolutions(
height=args.height, width=args.width, num_frames=args.num_frames,
spatial_scale=1, vae_spatial_compression=16, vae_temporal_compression=4,
patch_size=patch_size, enabled=False,
)
spatial_mode = "off"
lat_h, lat_w = active_plan.stage1_latent_height, active_plan.stage1_latent_width
lat_t = active_plan.latent_frames
in_ch = int(config["in_channels"])
print(f"[5B] latent {in_ch}x{lat_t}x{lat_h}x{lat_w}", flush=True)
total_start = time.perf_counter()
prompt_for_encode = args.prompt
enhance_backend = None
enhance_elapsed_s = 0.0
if args.enhance_prompt:
from fastvideo.mlx_runtime.prompt_enhance import enhance_prompt
enhancement = enhance_prompt(args.prompt, backend=args.enhance_prompt_backend)
prompt_for_encode = enhancement.enhanced
enhance_backend = enhancement.backend
enhance_elapsed_s = enhancement.elapsed_s
print(f"[enhance] backend={enhance_backend} in {enhance_elapsed_s:.2f}s", flush=True)
print(f"[enhance] prompt: {prompt_for_encode}", flush=True)
t0 = time.perf_counter()
prompt_cache_fingerprint = _prompt_cache_fingerprint(
prompt=args.prompt,
prompt_used=prompt_for_encode,
enhance_prompt=args.enhance_prompt,
enhance_prompt_backend=args.enhance_prompt_backend,
text_encoder_root=args.text_encoder_root,
max_sequence_length=512,
dtype="fp16",
)
prompt_cache_path = args.prompt_embeds_cache
if prompt_cache_path is None and args.prompt_cache:
prompt_cache_path = _default_prompt_cache_path(prompt_cache_fingerprint)
cached_embeds = load_prompt_cache(
prompt_cache_path,
prompt_cache_fingerprint,
)
if cached_embeds is not None:
embeds = torch.from_numpy(cached_embeds).contiguous()
else:
embeds = encode_prompt(
model_root=args.text_encoder_root,
prompt=prompt_for_encode,
max_sequence_length=512,
device_arg=args.text_encoder_device,
dtype_arg="fp16",
)
save_prompt_cache(
prompt_cache_path,
embeds.cpu().numpy(),
prompt_cache_fingerprint,
)
ehs = mx.array(embeds.numpy()).astype(mx.float16)
prompt_encode_s = time.perf_counter() - t0
print(f"[5B] prompt encoded {tuple(ehs.shape)} in {prompt_encode_s:.1f}s", flush=True)
t1 = time.perf_counter()
if args.mlx_checkpoint is not None:
dit = mlx_wan22_dit_from_mlx_checkpoint(
args.mlx_checkpoint,
compile=args.compile,
)
else:
dit = mlx_wan22_dit_from_diffusers_safetensors(
args.dit_checkpoint,
args.dit_config,
dtype="fp16",
compile=args.compile,
)
dit_load_s = time.perf_counter() - t1
print(f"[5B] DiT loaded in {dit_load_s:.1f}s", flush=True)
freqs = make_rotary_embeddings(config, latent_frames=lat_t, latent_height=lat_h, latent_width=lat_w)
gen = torch.Generator().manual_seed(args.seed)
noise = mx.array(
torch.randn(1, in_ch, lat_t, lat_h, lat_w, generator=gen, dtype=torch.float32).numpy()).astype(mx.float16)
steps = [int(s) for s in args.dmd_denoising_steps.split(",") if s.strip()]
t2 = time.perf_counter()
mx.reset_peak_memory()
latents = sample_wan22_dmd(
dit,
ehs,
noise,
freqs,
dmd_denoising_steps=steps,
flow_shift=args.flow_shift,
warp_denoising_step=not args.no_warp,
seed=args.renoise_seed,
)
if spatial_mode == "refine":
schedule, warped_steps = build_wan22_dmd_schedule(
steps, flow_shift=args.flow_shift, warp_denoising_step=not args.no_warp,
)
# The grid opens at sigma == 1, where the hand-off
# `(1 - sigma) * upsampled + sigma * noise` weights stage 1 at zero and
# refine silently becomes a plain full-res run. Drop the leading
# full-noise steps so stage 1 actually reaches stage 2.
stage2_warped = default_refine_timesteps(schedule, warped_steps)
stage2_steps = steps[len(warped_steps) - len(stage2_warped):]
sigma = schedule.sigma_for(stage2_warped[0])
print(f"[5B refine] stage-2 steps={stage2_steps} sigma={sigma:.4f} "
f"(stage-1 weight {1.0 - sigma:.4f})", flush=True)
latents = prepare_refine_latents(
latents, scale=args.refine_scale, sigma=sigma,
add_noise_flag=not args.no_refine_add_noise,
upsample_mode=args.refine_upsample_mode, seed=args.renoise_seed + 1,
)
freqs_stage2 = make_rotary_embeddings(
config, latent_frames=lat_t,
latent_height=active_plan.stage2_latent_height,
latent_width=active_plan.stage2_latent_width,
)
latents = sample_wan22_dmd(
dit, ehs, latents, freqs_stage2, dmd_denoising_steps=stage2_steps,
flow_shift=args.flow_shift, warp_denoising_step=not args.no_warp,
seed=args.renoise_seed + 2,
)
# spatial_mode == "fast_spatial" leaves the latents on the stage-1 grid;
# the resample happens after decode, in _postprocess_video.
denoise_s = time.perf_counter() - t2
peak = mx.get_peak_memory() / (1024**3)
print(f"[5B] denoise {len(steps)} steps in {denoise_s:.1f}s, peak {peak:.2f} GiB", flush=True)
latents_np = np.array(latents.astype(mx.float32))
if args.save_latents is not None:
args.save_latents.parent.mkdir(parents=True, exist_ok=True)
np.savez(args.save_latents, latents=latents_np, prompt=args.prompt, seed=args.seed)
print(f"[5B] wrote latents {args.save_latents}", flush=True)
if spatial_mode == "refine":
del freqs_stage2
del dit, latents, ehs, noise, freqs
cleanup_mlx()
metrics = decode_latents_to_video(
latents_np,
args.output_path,
fps=args.fps,
backend=args.decode_backend,
vae_dir=args.vae_root if args.decode_backend == "wan-vae" else None,
z_dim=in_ch,
)
# One h264 round-trip for both post-decode passes (see _postprocess_video).
rife_s = 0.0
rife_request = ({
"factor": args.fast_factor,
"target_frames": target_frames,
"sharpen": args.fast_sharpen,
} if args.fast else None)
spatial_request = fast_spatial_plan if spatial_mode == "fast_spatial" else None
if rife_request is not None or spatial_request is not None:
rife_start = time.perf_counter()
_postprocess_video(
video_path=args.output_path, fps=args.fps,
rife=rife_request, spatial=spatial_request,
)
rife_s = time.perf_counter() - rife_start
print(f"[5B] decoded via {metrics['backend']} in {metrics['decode_s']:.1f}s → {args.output_path}", flush=True)
summary = {
"output_path": str(args.output_path.resolve()),
"prompt": args.prompt,
"prompt_used": prompt_for_encode,
"enhance_prompt": args.enhance_prompt,
"enhance_backend": enhance_backend,
"enhance_elapsed_s": round(enhance_elapsed_s, 3),
"height": args.height,
"width": args.width,
"fps": args.fps,
"target_frames": target_frames,
"generated_frames": args.num_frames,
"seed": args.seed,
"renoise_seed": args.renoise_seed,
"dmd_denoising_steps": steps,
"flow_shift": args.flow_shift,
"warp": not args.no_warp,
"spatial_mode": spatial_mode,
"fast": args.fast,
"fast_factor": args.fast_factor if args.fast else None,
"fast_spatial_scale": args.fast_spatial_scale if args.fast_spatial else None,
"refine_scale": args.refine_scale if args.refine else None,
"decode_backend": args.decode_backend,
"prompt_encode_s": round(prompt_encode_s, 3),
"dit_load_s": round(dit_load_s, 3),
"denoise_s": round(denoise_s, 3),
"decode_s": round(metrics["decode_s"], 3),
"rife_s": round(rife_s, 3),
"wall_total_s": round(time.perf_counter() - total_start, 3),
"peak_gib": round(peak, 3),
"latent_shape": [in_ch, lat_t, lat_h, lat_w],
"stage2_latent_shape": [in_ch, lat_t, active_plan.stage2_latent_height, active_plan.stage2_latent_width],
"mlx_checkpoint": str(args.mlx_checkpoint.resolve()) if args.mlx_checkpoint else None,
}
if args.metrics_json is not None:
args.metrics_json.parent.mkdir(parents=True, exist_ok=True)
args.metrics_json.write_text(json.dumps(summary, indent=2) + "\n")
print(f"[5B] wrote metrics {args.metrics_json}", flush=True)
print(json.dumps(summary, indent=2), flush=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,130 @@
"""Compare Wan VAE and TAEHV decode on saved FastWan latents."""
from __future__ import annotations
import argparse
import json
import time
from pathlib import Path
import numpy as np
from examples.inference.basic.mlx_wan_prompt_to_video import DEFAULT_MODEL_ROOT, decode_latents_to_video
def _torch_mps_memory() -> dict[str, int | None]:
"""
Report current and recommended memory usage for the MPS backend.
Returns:
dict[str, int | None]: Memory metrics in bytes, or `None` values when
PyTorch or MPS is unavailable.
"""
try:
import torch
except ImportError:
return {
"current_allocated_bytes": None,
"driver_allocated_bytes": None,
"recommended_max_bytes": None,
}
if not torch.backends.mps.is_available():
return {
"current_allocated_bytes": None,
"driver_allocated_bytes": None,
"recommended_max_bytes": None,
}
return {
"current_allocated_bytes": int(torch.mps.current_allocated_memory()),
"driver_allocated_bytes": int(torch.mps.driver_allocated_memory()),
"recommended_max_bytes": int(torch.mps.recommended_max_memory()),
}
def _parse_backends(raw: str) -> list[str]:
"""
Parse and validate a comma-separated list of decoding backends.
Parameters:
raw (str): Comma-separated backend names.
Returns:
list[str]: Trimmed, supported backend names in input order.
Raises:
ValueError: If the input contains an unsupported backend.
"""
backends = [backend.strip() for backend in raw.split(",") if backend.strip()]
allowed = {"wan-vae", "taehv"}
unknown = sorted(set(backends) - allowed)
if unknown:
raise ValueError(f"Unsupported decode backends: {unknown}")
return backends
def main() -> None:
"""
Benchmark selected Wan latent decoding backends and record their performance metrics.
Loads the specified latent array, decodes it with each selected backend, exports the
results as MP4 files, and writes per-backend timing and Torch MPS memory metrics to
`metrics.json`.
"""
parser = argparse.ArgumentParser(description="Benchmark decode backends on saved Wan/FastWan latents.")
parser.add_argument("--model-root", type=Path, default=DEFAULT_MODEL_ROOT)
parser.add_argument("--latents-path", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, default=Path("video_samples/mlx_decode_benchmark"))
parser.add_argument("--backends", default="wan-vae,taehv")
parser.add_argument("--fps", type=int, default=16)
parser.add_argument("--torch-device", default="auto")
parser.add_argument("--torch-dtype", choices=("fp16", "fp32"), default="fp16")
parser.add_argument("--taehv-source-path", type=Path, default=None)
parser.add_argument("--taehv-checkpoint-path", type=Path, default=None)
parser.add_argument("--taehv-parallel", action="store_true")
args = parser.parse_args()
latents = np.load(args.latents_path)
args.output_dir.mkdir(parents=True, exist_ok=True)
rows = []
for backend in _parse_backends(args.backends):
print(f"=== Decode backend: {backend} ===")
before = _torch_mps_memory()
start = time.perf_counter()
output_path = args.output_dir / f"{args.latents_path.stem}_{backend}.mp4"
decode_latents_to_video(
model_root=args.model_root,
latents_np=latents,
output_path=output_path,
fps=args.fps,
device_arg=args.torch_device,
dtype_arg=args.torch_dtype,
backend=backend,
taehv_source_path=args.taehv_source_path,
taehv_checkpoint_path=args.taehv_checkpoint_path,
taehv_parallel=args.taehv_parallel,
)
elapsed = time.perf_counter() - start
after = _torch_mps_memory()
metrics = {
"backend": backend,
"latents_path": str(args.latents_path),
"latents_shape": list(latents.shape),
"decode_export_s": elapsed,
"torch_mps_current_before_bytes": before["current_allocated_bytes"],
"torch_mps_current_after_bytes": after["current_allocated_bytes"],
"torch_mps_driver_before_bytes": before["driver_allocated_bytes"],
"torch_mps_driver_after_bytes": after["driver_allocated_bytes"],
"torch_mps_recommended_max_bytes": after["recommended_max_bytes"],
"output_path": str(output_path),
}
rows.append(metrics)
print(json.dumps(metrics, indent=2))
metrics_path = args.output_dir / "metrics.json"
metrics_path.write_text(json.dumps(rows, indent=2))
print(f"Wrote decode metrics to: {metrics_path}")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,365 @@
"""Benchmark MLX FastWan quantization modes with one shared prompt encode."""
from __future__ import annotations
import argparse
import json
import time
from pathlib import Path
from typing import cast
import numpy as np
from examples.inference.basic.mlx_wan_prompt_to_video import (
DEFAULT_MODEL_ROOT,
decode_latents_to_video,
encode_prompt,
make_rotary_embeddings,
)
from fastvideo.mlx_runtime.memory import cleanup_mlx
def _parse_modes(raw: str) -> list[str]:
"""
Parse and validate a comma-separated list of quantization modes.
Parameters:
raw (str): Comma-separated mode names.
Returns:
list[str]: Normalized, whitespace-trimmed mode names.
Raises:
ValueError: If any mode is unsupported.
"""
modes = [mode.strip() for mode in raw.split(",") if mode.strip()]
allowed = {"none", "int8", "int4", "mxfp8", "mxfp4", "nvfp4"}
unknown = sorted(set(modes) - allowed)
if unknown:
raise ValueError(f"Unsupported modes: {unknown}")
return modes
def _latent_delta_metrics(candidate: np.ndarray, baseline: np.ndarray) -> dict[str, float]:
"""
Compare candidate and baseline latent arrays using error and signal-quality metrics.
Parameters:
candidate (np.ndarray): Latent array to evaluate.
baseline (np.ndarray): Reference latent array for comparison.
Returns:
dict[str, float]: Mean squared error, mean absolute error, maximum absolute
error, and signal-to-noise ratio in decibels between the arrays.
"""
diff = candidate.astype(np.float32) - baseline.astype(np.float32)
mse = float(np.mean(np.square(diff)))
mae = float(np.mean(np.abs(diff)))
max_abs = float(np.max(np.abs(diff)))
signal = float(np.mean(np.square(baseline.astype(np.float32))))
return {
"latent_mse_vs_fp16": mse,
"latent_mae_vs_fp16": mae,
"latent_max_abs_vs_fp16": max_abs,
"latent_snr_db_vs_fp16": float(10.0 * np.log10(signal / mse)) if mse > 0 else float("inf"),
}
def _torch_mps_memory() -> dict[str, int | None]:
"""
Report PyTorch MPS memory statistics when PyTorch MPS is available.
Returns:
dict[str, int | None]: A mapping of MPS memory metric names to byte counts, or `None` values when PyTorch or MPS is unavailable.
"""
try:
import torch
except ImportError:
return {
"torch_mps_current_allocated_bytes": None,
"torch_mps_driver_allocated_bytes": None,
"torch_mps_recommended_max_bytes": None,
}
if not torch.backends.mps.is_available():
return {
"torch_mps_current_allocated_bytes": None,
"torch_mps_driver_allocated_bytes": None,
"torch_mps_recommended_max_bytes": None,
}
return {
"torch_mps_current_allocated_bytes": int(torch.mps.current_allocated_memory()),
"torch_mps_driver_allocated_bytes": int(torch.mps.driver_allocated_memory()),
"torch_mps_recommended_max_bytes": int(torch.mps.recommended_max_memory()),
}
def _decode_with_metrics(*, args, latents: np.ndarray, output_path: Path) -> dict[str, float | int | None | str]:
"""
Decode latents to a video and collect export timing and PyTorch MPS memory metrics.
Parameters:
args: Configuration values for decoding and video export.
latents (np.ndarray): Latent representation to decode.
output_path (Path): Destination path for the exported video.
Returns:
dict[str, float | int | None | str]: Video export duration and PyTorch MPS memory measurements.
"""
before = _torch_mps_memory()
decode_start = time.perf_counter()
decode_latents_to_video(
model_root=args.model_root,
latents_np=latents,
output_path=output_path,
fps=args.fps,
device_arg=args.torch_device,
dtype_arg=args.torch_dtype,
backend=args.decode_backend,
taehv_source_path=args.taehv_source_path,
taehv_checkpoint_path=args.taehv_checkpoint_path,
taehv_parallel=args.taehv_parallel,
)
decode_time = time.perf_counter() - decode_start
after = _torch_mps_memory()
return {
"decode_export_s": decode_time,
"decode_torch_mps_current_before_bytes": before["torch_mps_current_allocated_bytes"],
"decode_torch_mps_current_after_bytes": after["torch_mps_current_allocated_bytes"],
"decode_torch_mps_driver_before_bytes": before["torch_mps_driver_allocated_bytes"],
"decode_torch_mps_driver_after_bytes": after["torch_mps_driver_allocated_bytes"],
"decode_torch_mps_recommended_max_bytes": after["torch_mps_recommended_max_bytes"],
}
def _run_one_mode(
*,
mode: str,
args,
config: dict,
checkpoint_path: Path,
config_path: Path,
prompt_embeds,
freqs_cis,
):
"""
Run denoising for one quantization mode and collect performance and memory metrics.
Parameters:
mode (str): Quantization mode to benchmark.
args: Benchmark configuration, including dtype, dimensions, seed, scheduler, and denoising settings.
config (dict): Model configuration containing the input channel count.
checkpoint_path (Path): Path to the transformer checkpoint.
config_path (Path): Path to the transformer configuration.
prompt_embeds: Encoded prompt embeddings shared across benchmark modes.
freqs_cis: Rotary positional embeddings used during denoising.
Returns:
dict: The mode name, generated latent array, and metrics for model loading,
denoising, step timing, and MLX memory usage.
"""
import mlx.core as mx
import torch
from fastvideo.benchmarks.mlx_fastwan_bench import denoise_dmd_on_device
from fastvideo.models.schedulers.scheduling_flow_match_euler_discrete import FlowMatchEulerDiscreteScheduler
from fastvideo.mlx_runtime.fastwan import mlx_dit_from_diffusers_safetensors
from fastvideo.mlx_runtime.sampling import MLXDMDSchedule, dmd_step
mx_dtype = mx.float16 if args.mlx_dtype == "fp16" else mx.float32
quantization = None if mode == "none" else mode
latent_frames = (args.num_frames - 1) // 4 + 1
latent_height = args.height // 8
latent_width = args.width // 8
load_start = time.perf_counter()
mx.clear_cache()
mx.reset_peak_memory()
dit = mlx_dit_from_diffusers_safetensors(
checkpoint_path,
config_path,
dtype=args.mlx_dtype,
quantization=quantization,
)
load_time = time.perf_counter() - load_start
load_peak_memory = mx.get_peak_memory()
scheduler = FlowMatchEulerDiscreteScheduler(shift=args.flow_shift)
schedule = MLXDMDSchedule.from_torch_scheduler(scheduler)
timesteps = [int(step.strip()) for step in args.dmd_denoising_steps.split(",") if step.strip()]
# Same torch generator sequence as the original host-round-trip loop
# (initial latents first, then one re-noise draw per intermediate step),
# so every mode still shares identical stochasticity.
generator = torch.Generator(device="cpu").manual_seed(args.seed)
latents_seed = torch.randn(
(1, int(config["in_channels"]), latent_frames, latent_height, latent_width),
generator=generator,
dtype=torch.float32,
).numpy()
renoise_by_step = [
torch.randn(latents_seed.shape, generator=generator, dtype=torch.float32).numpy()
for _ in range(max(0, len(timesteps) - 1))
]
latents = mx.array(latents_seed).astype(mx_dtype)
encoder_hidden_states = mx.array(prompt_embeds.numpy()).astype(mx_dtype)
denoise_start = time.perf_counter()
mx.reset_peak_memory()
latents_np, step_times = denoise_dmd_on_device(
mx=mx,
dit=dit,
latents=latents,
encoder_hidden_states=encoder_hidden_states,
freqs_cis=freqs_cis,
timesteps=timesteps,
renoise_by_step=renoise_by_step,
schedule=schedule,
dmd_step=dmd_step,
mx_dtype=mx_dtype,
)
denoise_time = time.perf_counter() - denoise_start
denoise_peak_memory = mx.get_peak_memory()
active_memory = mx.get_active_memory()
return {
"mode": mode,
"latents": latents_np,
"metrics": {
"mlx_dit_load_s": load_time,
"mlx_denoise_s": denoise_time,
"mlx_denoise_first_step_s": step_times[0] if step_times else None,
"mlx_load_peak_bytes": int(load_peak_memory),
"mlx_denoise_peak_bytes": int(denoise_peak_memory),
"mlx_active_after_denoise_bytes": int(active_memory),
},
}
def main() -> None:
"""
Run the MLX FastWan quantization benchmark for the selected modes and write latency, memory, output, and latent-difference metrics to the output directory.
"""
parser = argparse.ArgumentParser(description="Benchmark MLX FastWan quantization modes.")
parser.add_argument("--model-root", type=Path, default=DEFAULT_MODEL_ROOT)
parser.add_argument("--prompt", default="A snow leopard walks across a windy mountain ridge.")
parser.add_argument("--height", type=int, default=192)
parser.add_argument("--width", type=int, default=320)
parser.add_argument("--num-frames", type=int, default=17)
parser.add_argument("--dmd-denoising-steps", default="1000,757,522")
parser.add_argument("--flow-shift", type=float, default=8.0)
parser.add_argument("--max-sequence-length", type=int, default=256)
parser.add_argument("--seed", type=int, default=1024)
parser.add_argument("--fps", type=int, default=16)
parser.add_argument("--torch-device", default="auto")
parser.add_argument("--torch-dtype", choices=("fp16", "fp32"), default="fp16")
parser.add_argument("--mlx-dtype", choices=("fp16", "fp32"), default="fp16")
parser.add_argument("--modes", default="none,int8,int4,mxfp8,mxfp4,nvfp4")
parser.add_argument("--output-dir", type=Path, default=Path("video_samples/mlx_quant_benchmark"))
parser.add_argument("--decode-backend", choices=("none", "wan-vae", "taehv"), default="taehv")
parser.add_argument("--taehv-source-path", type=Path, default=None)
parser.add_argument("--taehv-checkpoint-path", type=Path, default=None)
parser.add_argument("--taehv-parallel", action="store_true")
args = parser.parse_args()
import mlx.core as mx
import torch
mx.random.seed(args.seed)
torch.manual_seed(args.seed)
args.output_dir.mkdir(parents=True, exist_ok=True)
config_path = args.model_root / "transformer/config.json"
checkpoint_path = args.model_root / "transformer/diffusion_pytorch_model.safetensors"
config = json.loads(config_path.read_text())
latent_frames = (args.num_frames - 1) // 4 + 1
latent_height = args.height // 8
latent_width = args.width // 8
prompt_start = time.perf_counter()
prompt_embeds = encode_prompt(
model_root=args.model_root,
prompt=args.prompt,
max_sequence_length=args.max_sequence_length,
device_arg=args.torch_device,
dtype_arg=args.torch_dtype,
)
prompt_time = time.perf_counter() - prompt_start
freqs_cis = make_rotary_embeddings(
config,
latent_frames=latent_frames,
latent_height=latent_height,
latent_width=latent_width,
)
from fastvideo.mlx_runtime.fastwan import UnsupportedMLXQuantizationError
baseline_latents = None
rows = []
for mode in _parse_modes(args.modes):
print(f"=== MLX quant mode: {mode} ===")
mode_start = time.perf_counter()
try:
result = _run_one_mode(
mode=mode,
args=args,
config=config,
checkpoint_path=checkpoint_path,
config_path=config_path,
prompt_embeds=prompt_embeds,
freqs_cis=freqs_cis,
)
except UnsupportedMLXQuantizationError as exc:
print(f"skipping mode (unsupported by this MLX build): {exc}")
rows.append({"mode": mode, "status": "unsupported_by_mlx", "error": str(exc)})
continue
cleanup_mlx(mx)
latents = result["latents"]
if baseline_latents is None:
baseline_latents = latents
latent_path = args.output_dir / f"latents_{mode}.npy"
np.save(latent_path, latents)
decode_time = 0.0
decode_metrics = {}
output_path = None
if args.decode_backend != "none":
output_path = args.output_dir / f"video_{mode}_{args.decode_backend}_{args.height}x{args.width}x{args.num_frames}.mp4"
decode_metrics = _decode_with_metrics(args=args, latents=latents, output_path=output_path)
decode_time = cast(float, decode_metrics["decode_export_s"])
mode_total = time.perf_counter() - mode_start
mlx_denoise_peak_bytes = int(result["metrics"]["mlx_denoise_peak_bytes"])
mlx_active_bytes = int(result["metrics"]["mlx_active_after_denoise_bytes"])
metrics = {
"mode": mode,
"status": "ok",
"prompt_encode_shared_s": prompt_time,
"height": args.height,
"width": args.width,
"num_frames": args.num_frames,
"decode_backend": args.decode_backend,
"decode_export_s": decode_time,
"mode_total_excluding_shared_prompt_s": mode_total,
"mode_total_including_shared_prompt_s": mode_total + prompt_time,
"latents_path": str(latent_path),
"output_path": str(output_path) if output_path else None,
"mlx_denoise_peak_gib": mlx_denoise_peak_bytes / (1024**3),
"mlx_active_after_denoise_gib": mlx_active_bytes / (1024**3),
"mlx_dit_peak_under_16gb": mlx_denoise_peak_bytes < 16 * 1024**3,
"mlx_dit_active_under_16gb": mlx_active_bytes < 16 * 1024**3,
"mac_16gb_status": (
"dit_memory_fits_16gb_measured_decode_separately"
if mlx_denoise_peak_bytes < 16 * 1024**3 else "dit_memory_exceeds_16gb"
),
**result["metrics"],
**decode_metrics,
**_latent_delta_metrics(latents, baseline_latents),
}
rows.append(metrics)
print(json.dumps(metrics, indent=2))
metrics_path = args.output_dir / "metrics.json"
metrics_path.write_text(json.dumps(rows, indent=2))
print(f"Wrote benchmark metrics to: {metrics_path}")
if __name__ == "__main__":
main()
@@ -0,0 +1,104 @@
"""Compare generated MP4s against a reference MP4 with simple pixel metrics."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import numpy as np
def _read_video(path: Path) -> np.ndarray:
"""
Read all frames from a video file as an RGB NumPy array.
Parameters:
path (Path): Path to the video file.
Returns:
np.ndarray: Video frames stacked along the first axis.
Raises:
ValueError: If the video contains no readable frames.
"""
import cv2
cap = cv2.VideoCapture(str(path))
frames = []
try:
while True:
ok, frame_bgr = cap.read()
if not ok:
break
frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
frames.append(frame_rgb)
finally:
cap.release()
if not frames:
raise ValueError(f"No frames read from {path}")
return np.stack(frames, axis=0)
def _metrics(candidate: np.ndarray, reference: np.ndarray) -> dict[str, float | int | list[int]]:
"""
Compute pixel-level comparison metrics between candidate and reference video frames.
Parameters:
candidate (np.ndarray): Candidate video frames in frame, height, width, and channel order.
reference (np.ndarray): Reference video frames with the same shape as the candidate.
Returns:
dict[str, float | int | list[int]]: Frame dimensions and pixel comparison metrics, including MSE, MAE, maximum absolute difference, and PSNR in decibels.
Raises:
ValueError: If the candidate and reference arrays have different shapes.
"""
if candidate.shape != reference.shape:
raise ValueError(f"Shape mismatch: candidate={candidate.shape}, reference={reference.shape}")
candidate_f = candidate.astype(np.float32)
reference_f = reference.astype(np.float32)
diff = candidate_f - reference_f
mse = float(np.mean(np.square(diff)))
mae = float(np.mean(np.abs(diff)))
max_abs = float(np.max(np.abs(diff)))
psnr = float(20.0 * np.log10(255.0 / np.sqrt(mse))) if mse > 0 else float("inf")
return {
"frames": int(candidate.shape[0]),
"height": int(candidate.shape[1]),
"width": int(candidate.shape[2]),
"channels": int(candidate.shape[3]),
"mse_vs_reference": mse,
"mae_vs_reference": mae,
"max_abs_vs_reference": max_abs,
"psnr_db_vs_reference": psnr,
}
def main() -> None:
"""Compare candidate MP4 videos with a reference and write pixel-level metrics to a JSON file."""
parser = argparse.ArgumentParser(description="Compare MP4s against a reference MP4.")
parser.add_argument("--reference", type=Path, required=True)
parser.add_argument("--candidates", type=Path, nargs="+", required=True)
parser.add_argument("--metrics-json", type=Path, required=True)
args = parser.parse_args()
reference = _read_video(args.reference)
rows = []
for candidate_path in args.candidates:
candidate = _read_video(candidate_path)
row = {
"reference_path": str(args.reference),
"candidate_path": str(candidate_path),
**_metrics(candidate, reference),
}
rows.append(row)
print(json.dumps(row, indent=2))
args.metrics_json.parent.mkdir(parents=True, exist_ok=True)
args.metrics_json.write_text(json.dumps(rows, indent=2))
print(f"Wrote video quality metrics to: {args.metrics_json}")
if __name__ == "__main__":
main()
+40 -11
View File
@@ -17,7 +17,7 @@ Runtime-JIT kernels (no build step, ship in every wheel/image):
| Kernels | Where | Used when |
|---|---|---|
| Triton: STA, VSA block-sparse, SLA, fused compress+topk, FP4 QAT training, quant/norm utils | `python/fastvideo_kernel/triton_kernels/` | automatic fallback when the matching C++ op is absent (`ops.py`, `turbodiffusion_ops.py`) |
| FA4 CuTe-DSL block-sparse (VSA-256 fastpath on `sm_100`) | `block_sparse_attn_cute_fwd.py` | optional `flash_attn.cute` dependency, see below |
| FA4 CuTe-DSL block-sparse forward/backward (VSA-128/256 fastpath on `sm_100`) | `block_sparse_attn_cute_fwd.py` | optional `flash_attn.cute` dependency, see below |
| VMoBA `moba_attn_varlen` | `vmoba.py` | wraps flash-attn varlen |
## What gets built where, and when
@@ -64,28 +64,49 @@ cd fastvideo-kernel
./build.sh --rocm
```
### Optional: FA4 CuTe block-sparse backend (VSA-256 fastpath)
### Optional: FA4 CuTe block-sparse backend (VSA-128/256 fastpath)
The VSA-256 fastpath (tile volume 256, on NVIDIA Blackwell / sm_100) routes to the
The VSA-128/256 fastpaths (tile volume 128 or 256, on NVIDIA Blackwell / sm_100) route to the
FlashAttention-4 CuTe-DSL block-sparse kernel exposed as `flash_attn.cute`. This is
an **optional** dependency: it is imported lazily, and `video_sparse_attn`
transparently falls back to the Triton backend when it is absent (so the package is
fully usable without it).
The symbols the fastpath needs (`flash_attn.cute.block_sparsity.BlockSparseTensorsTorch`,
`flash_attn.cute.interface._flash_attn_fwd`) are provided upstream by
The symbols the fastpath needs (`flash_attn.cute.block_sparsity.BlockSparseTensorsTorch`
and the public/private forward-backward bridges in `flash_attn.cute.interface`) are provided upstream by
[Dao-AILab/flash-attention](https://github.com/Dao-AILab/flash-attention). Pin to
commit `940cd9680f3315f2f06b43ab5bea2c2cf2d96806`, the revision FastVideo pins as
commit `14c377950125c70b7a9dabf9c561fca53715ac7d`, the revision FastVideo pins as
the `flash-attn-4` source in the repo-root `pyproject.toml`; other revisions may
have an incompatible `_flash_attn_fwd` signature.
have incompatible block-sparse forward/backward interfaces.
Install it under its distribution name so its own runtime stack resolves with it.
Do **not** pre-install `nvidia-cutlass-dsl` by hand: this revision pins
`nvidia-cutlass-dsl==4.6.0.dev0` exactly, and a hand-installed 4.5.x floor either
gets silently upgraded or, if something else holds it back, leaves the CuTe
kernels broken.
```bash
pip install "nvidia-cutlass-dsl>=4.5.0" torchvision
pip install "git+https://github.com/Dao-AILab/flash-attention.git@940cd9680f3315f2f06b43ab5bea2c2cf2d96806#subdirectory=flash_attn/cute"
pip install torchvision
pip install "flash-attn-4 @ git+https://github.com/Dao-AILab/flash-attention.git@14c377950125c70b7a9dabf9c561fca53715ac7d#subdirectory=flash_attn/cute"
```
The CuTe kernel JIT-compiles on first use. Verified on Blackwell (sm_100) against
`tests/test_vsa256_forward*.py`.
That resolves `nvidia-cutlass-dsl` to 4.6.0.dev0 and `quack-kernels` to 0.5.3, a
combination this revision works with. A mismatched CuTe DSL only surfaces when the
kernel JIT-compiles, so the error points at CuTe internals rather than at the
install:
| Error on first VSA-128/256 CuTe call | Cause |
|---|---|
| `TypeError: fmax() missing 1 required positional argument: 'b'` | `nvidia-cutlass-dsl` 4.5.x |
| `AttributeError: module 'cutlass.cute.core' has no attribute 'ThrMma'` | `quack-kernels` older than 0.5.1 |
| `ImportError: cannot import name 'alloc_reserved_mbarrier'` | `quack-kernels` 0.6.2 or newer |
An environment whose `flash_attn.cute` came from a prebuilt flash-attn wheel rather
than from this pin hits the first row; that is what the overlay step in
`docker/Dockerfile` works around.
The CuTe kernels JIT-compile on first use. Forward and backward are verified on
Blackwell (sm_100) against `tests/test_vsa128_*.py` and `tests/test_vsa256_*.py`.
## Usage
@@ -142,6 +163,14 @@ After building/installing `fastvideo-kernel`, run:
```bash
cd fastvideo-kernel
python benchmarks/bench_vsa.py --batch_size 1 --num_heads 16 --head_dim 128 --q_seq_lens 49152 --topk 64
# VSA-256 FA4 CuTe forward/backward on Blackwell
python benchmarks/bench_vsa.py --block_size 256 --use_cute \
--batch_size 1 --num_heads 12 --head_dim 128 --q_seq_lens 39936 --topk 20
# VSA-128 FA4 CuTe forward/backward on Blackwell
python benchmarks/bench_vsa.py --block_size 128 --use_cute \
--batch_size 1 --num_heads 12 --head_dim 128 --q_seq_lens 39936 --topk 40
```
### TurboDiffusion Kernels
+48 -20
View File
@@ -2,8 +2,9 @@
"""
Benchmark VSA *wrapper* performance (forward + backward) and report TFLOPs.
This script benchmarks the autograd-enabled wrapper:
- fastvideo_kernel.block_sparse_attn.block_sparse_attn
This script benchmarks the autograd-enabled wrappers:
- 64-token TK/Triton: fastvideo_kernel.block_sparse_attn.block_sparse_attn
- 128/256-token Triton/CuTe: fastvideo_kernel.block_sparse_attn_256
So measured time includes wrapper overhead (map->index conversion, dispatch) plus kernel time.
"""
@@ -23,9 +24,6 @@ try:
except Exception as e: # pragma: no cover
raise ImportError("This benchmark requires triton (for triton.testing.do_bench).") from e
BLOCK_M = 64
BLOCK_N = 64
def set_seed(seed: int = 42) -> None:
random.seed(seed)
@@ -41,7 +39,11 @@ def parse_arguments() -> argparse.Namespace:
p.add_argument("--num_heads", type=int, default=12)
p.add_argument("--head_dim", type=int, default=128, choices=[64, 128])
p.add_argument("--topk", type=int, default=None, help="KV blocks per Q block (default: ~90%% sparsity)")
p.add_argument("--q_seq_lens", type=int, nargs="+", default=[49152], help="Q sequence lengths (must be /64)")
p.add_argument("--q_seq_lens",
type=int,
nargs="+",
default=[49152],
help="Q sequence lengths (must be divisible by --block_size)")
p.add_argument("--kv_seq_lens",
type=int,
nargs="+",
@@ -51,9 +53,13 @@ def parse_arguments() -> argparse.Namespace:
p.add_argument("--rep", type=int, default=20)
p.add_argument("--seed", type=int, default=42)
p.add_argument("--dtype", type=str, default="bf16", choices=["bf16", "fp16"])
p.add_argument("--block_size", type=int, default=64, choices=[64, 128, 256])
p.add_argument("--force_triton",
action="store_true",
help="Force wrapper to use Triton path (if supported by shapes).")
p.add_argument("--use_cute",
action="store_true",
help="Use the optional FA4 CuTe forward/backward path (requires --block_size 128 or 256).")
return p.parse_args()
@@ -84,18 +90,38 @@ def bench_ms(fn: Callable[[], object], warmup: int, rep: int) -> float:
return do_bench(fn, warmup=warmup, rep=rep, quantiles=None)
def _configure_backend(args: argparse.Namespace) -> None:
if args.use_cute and args.block_size not in (128, 256):
raise ValueError("--use_cute requires --block_size 128 or 256")
if args.use_cute and args.force_triton:
raise ValueError("--use_cute and --force_triton are mutually exclusive")
if args.force_triton:
os.environ.pop("FASTVIDEO_VSA_CUTEDSL", None)
os.environ["FASTVIDEO_KERNEL_VSA_FORCE_TRITON"] = "1"
elif args.use_cute:
os.environ.pop("FASTVIDEO_VSA_TRITON", None)
os.environ.pop("FASTVIDEO_KERNEL_VSA_FORCE_TRITON", None)
os.environ["FASTVIDEO_VSA_CUTEDSL"] = "1"
def main() -> None:
args = parse_arguments()
set_seed(args.seed)
_configure_backend(args)
dtype = torch.bfloat16 if args.dtype == "bf16" else torch.float16
if args.force_triton:
os.environ["FASTVIDEO_KERNEL_VSA_FORCE_TRITON"] = "1"
from fastvideo_kernel.block_sparse_attn import block_sparse_attn
from fastvideo_kernel.block_sparse_attn_256 import block_sparse_attn_128, block_sparse_attn_256
bs, h, d = args.batch_size, args.num_heads, args.head_dim
block_size = args.block_size
attention = {
64: block_sparse_attn,
128: block_sparse_attn_128,
256: block_sparse_attn_256,
}[block_size]
kv_seq_lens = args.kv_seq_lens
if kv_seq_lens is None:
kv_seq_lens = args.q_seq_lens
@@ -105,20 +131,22 @@ def main() -> None:
print("VSA Block-Sparse Attention Benchmark (WRAPPER)")
print(f"device: {torch.cuda.get_device_name(0)}")
print(f"batch={bs}, heads={h}, head_dim={d}, dtype={args.dtype}")
print(f"BLOCK_M={BLOCK_M}, BLOCK_N={BLOCK_N}")
print(f"block_size={block_size}")
print("NOTE: timings include wrapper overhead (map->index + dispatch).")
if args.force_triton:
if args.use_cute:
print("dispatch: FA4 CuTe")
elif args.force_triton:
print("dispatch: forced Triton (FASTVIDEO_KERNEL_VSA_FORCE_TRITON=1)")
else:
print("dispatch: SM90 if available, else Triton")
for q_len, kv_len in zip(args.q_seq_lens, kv_seq_lens):
if q_len % BLOCK_M != 0 or kv_len % BLOCK_N != 0:
print(f"[skip] q_len={q_len}, kv_len={kv_len} must be divisible by 64")
if q_len % block_size != 0 or kv_len % block_size != 0:
print(f"[skip] q_len={q_len}, kv_len={kv_len} must be divisible by {block_size}")
continue
num_q_blocks = q_len // BLOCK_M
num_kv_blocks = kv_len // BLOCK_N
num_q_blocks = q_len // block_size
num_kv_blocks = kv_len // block_size
topk = args.topk if args.topk is not None else max(1, num_kv_blocks // 10)
topk = min(topk, num_kv_blocks)
@@ -129,11 +157,11 @@ def main() -> None:
q, k, v = create_qkv(bs, h, q_len, kv_len, d, dtype)
block_map = make_block_map(bs, h, num_q_blocks, num_kv_blocks, topk)
# Variable block sizes: default full blocks (64 tokens per KV block)
variable_block_sizes = torch.full((num_kv_blocks, ), BLOCK_N, dtype=torch.int32, device="cuda")
# Variable block sizes: default full logical blocks.
variable_block_sizes = torch.full((num_kv_blocks, ), block_size, dtype=torch.int32, device="cuda")
def _fwd():
return block_sparse_attn(q, k, v, block_map, variable_block_sizes)
return attention(q, k, v, block_map, variable_block_sizes)
fwd_ms = bench_ms(_fwd, warmup=args.warmup, rep=args.rep)
@@ -142,7 +170,7 @@ def main() -> None:
q_ = q.detach().requires_grad_(True)
k_ = k.detach().requires_grad_(True)
v_ = v.detach().requires_grad_(True)
o_, _aux_ = block_sparse_attn(q_, k_, v_, block_map, variable_block_sizes)
o_, _aux_ = attention(q_, k_, v_, block_map, variable_block_sizes)
og = torch.randn_like(o_)
loss = (o_ * og).sum()
@@ -156,7 +184,7 @@ def main() -> None:
rep=max(5, args.rep // 2),
)
flops = flops_sparse_attention(bs, h, d, q_len, topk, BLOCK_N)
flops = flops_sparse_attention(bs, h, d, q_len, topk, block_size)
fwd_tflops = flops / fwd_ms * 1e-12 * 1e3
# Rough backward multiplier (attention backward typically ~2-3x forward)
bwd_tflops = (2.5 * flops) / bwd_ms * 1e-12 * 1e3
@@ -1,4 +1,4 @@
"""VSA-256 block-sparse attention wrapper.
"""VSA-128/256 block-sparse attention wrappers.
The default 256-block path is Triton: it expands the logical 256-block map
to the existing 64-block Triton kernel via a dense 4x4 expansion per logical
@@ -7,8 +7,8 @@ edge ("route A"), and requires no optional dependencies.
The FA4 CuTe block-sparse fastpath (intended for Blackwell sm_100+) is
*opt-in* via ``FASTVIDEO_VSA_CUTEDSL=1``. It routes to
:mod:`fastvideo_kernel.block_sparse_attn_cute_fwd`, which natively operates
on 128-token KV blocks (this wrapper expands the logical 256-block map /
sizes into that physical 128-block representation). The CuTe kernel
on 128-token Q/KV blocks (the 256 wrapper expands its logical KV map and
sizes into that physical representation). The CuTe kernel
(``flash_attn.cute`` with block-sparsity) is an optional dependency,
imported lazily only when this fastpath is selected.
@@ -35,7 +35,7 @@ _KV_BLOCK_TRITON = 64 # Existing Triton path uses 64-token KV blocks.
def _resolve_backend() -> str:
"""Pick the backend for the 256-block VSA path.
"""Pick the backend for the 128/256-block VSA paths.
Default is Triton (no optional deps). The FA4 CuTe fastpath is opt-in
via ``FASTVIDEO_VSA_CUTEDSL=1`` and requires the optional FA4 CuTe
@@ -49,6 +49,26 @@ def _resolve_backend() -> str:
return "triton"
def _expand_mask_and_sizes_128_to_64(
logical_mask_128: torch.Tensor,
logical_kv_sizes_128: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""Expand a [B, H, Qb128, KVb128] map to 64-token Triton tiles."""
expanded_mask = logical_mask_128.repeat_interleave(2, dim=2).repeat_interleave(2, dim=3)
sizes_i32 = logical_kv_sizes_128.to(torch.int32)
offsets = torch.tensor(
[0, _KV_BLOCK_TRITON],
dtype=torch.int32,
device=sizes_i32.device,
)
expanded_sizes = torch.clamp(
sizes_i32[:, None] - offsets[None, :],
min=0,
max=_KV_BLOCK_TRITON,
).reshape(-1)
return expanded_mask, expanded_sizes
def _expand_mask_and_sizes_256_to_128(
logical_mask_256: torch.Tensor,
logical_kv_sizes_256: torch.Tensor,
@@ -112,6 +132,63 @@ def _triton_via_route_a(
return block_sparse_attn_triton(q, k, v, q2k_idx, q2k_num, sizes_64)
def _triton_via_route_a_128(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
logical_mask_128: torch.Tensor,
logical_kv_sizes_128: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
from .triton_kernels.index import map_to_index as triton_map_to_index
mask_64, sizes_64 = _expand_mask_and_sizes_128_to_64(logical_mask_128, logical_kv_sizes_128)
q2k_idx, q2k_num = triton_map_to_index(mask_64.to(torch.bool))
return block_sparse_attn_triton(q, k, v, q2k_idx, q2k_num, sizes_64)
def block_sparse_attn_128(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
logical_block_map_128: torch.Tensor,
logical_variable_block_sizes_128: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""VSA-128 sparse-branch entrypoint for [B, H, S, D] inputs."""
if logical_block_map_128.dim() == 3:
logical_block_map_128 = logical_block_map_128.unsqueeze(0)
if _resolve_backend() == "triton":
return _triton_via_route_a_128(q, k, v, logical_block_map_128, logical_variable_block_sizes_128)
from .block_sparse_attn_cute_fwd import block_sparse_attn_cute_fwd
return block_sparse_attn_cute_fwd(q, k, v, logical_block_map_128, logical_variable_block_sizes_128)
def block_sparse_attn_128_bshd(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
logical_block_map_128: torch.Tensor,
logical_variable_block_sizes_128: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""VSA-128 sparse-branch entrypoint for [B, S, H, D] inputs."""
if logical_block_map_128.dim() == 3:
logical_block_map_128 = logical_block_map_128.unsqueeze(0)
if _resolve_backend() == "triton":
out_bhsd, aux = _triton_via_route_a_128(
q.transpose(1, 2).contiguous(),
k.transpose(1, 2).contiguous(),
v.transpose(1, 2).contiguous(),
logical_block_map_128,
logical_variable_block_sizes_128,
)
return out_bhsd.transpose(1, 2).contiguous(), aux
from .block_sparse_attn_cute_fwd import block_sparse_attn_cute_fwd_bshd
return block_sparse_attn_cute_fwd_bshd(q, k, v, logical_block_map_128, logical_variable_block_sizes_128)
def block_sparse_attn_256(
q: torch.Tensor,
k: torch.Tensor,
@@ -1,18 +1,18 @@
"""CuTe-DSL block-sparse attention forward kernel.
"""FA4 CuTe-DSL block-sparse attention adapter.
Thin wrapper around `flash_attn.cute.interface._flash_attn_fwd` that adapts
VSA's `(block_map, variable_block_sizes)` inputs into FA4's
`BlockSparseTensorsTorch` representation and the per-KV-block validity mask.
This module adapts VSA's ``(block_map, variable_block_sizes)`` inputs into
FA4's forward and backward ``BlockSparseTensorsTorch`` representations.
FA4's public ``flash_attn_func`` owns the forward/backward autograd bridge.
Both [B, H, S, D] (BHSD) and [B, S, H, D] (BSHD) entrypoints are provided.
The BSHD variant is preferred from VSA-256 callers to avoid layout
The BSHD variant is preferred from VSA-128/256 callers to avoid layout
round-trips on the hot path.
The FA4 CuTe block-sparse kernel (``flash_attn.cute`` with
``block_sparsity``) is an *optional* dependency: it is imported lazily and
only exercised when the VSA-256 CuTe fastpath is explicitly selected
(``FASTVIDEO_VSA_CUTEDSL=1``). The default VSA-256 path is Triton and does
not require it. Also needs ``nvidia-cutlass-dsl`` and ``quack-kernels``.
only exercised when the VSA-128/256 CuTe fastpath is explicitly selected
(``FASTVIDEO_VSA_CUTEDSL=1``). The default path is Triton and does not require
it. Also needs ``nvidia-cutlass-dsl`` and ``quack-kernels``.
"""
from __future__ import annotations
@@ -22,13 +22,14 @@ from typing import Tuple
import torch
_FA4_IMPORT_HINT = ("VSA-256 CuTe fastpath requires a FlashAttention-4 CuTe build that "
_FA4_IMPORT_HINT = ("VSA-128/256 CuTe fastpath requires a FlashAttention-4 CuTe build that "
"provides `flash_attn.cute` with block-sparsity support (plus "
"`nvidia-cutlass-dsl` and `quack-kernels`). This is an optional "
"dependency; the default VSA-256 path is Triton. Install the FA4 CuTe "
"dependency; the default path is Triton. Install the FA4 CuTe "
"build and set FASTVIDEO_VSA_CUTEDSL=1 to enable the CuTe fastpath.")
@functools.lru_cache(maxsize=1)
def _load_fa4_cute():
"""Lazily import the optional FA4 CuTe block-sparse symbols.
@@ -38,14 +39,39 @@ def _load_fa4_cute():
"""
try:
from flash_attn.cute.block_sparsity import BlockSparseTensorsTorch
from flash_attn.cute.interface import _flash_attn_fwd
from flash_attn.cute.interface import (
_flash_attn_bwd,
_flash_attn_fwd,
flash_attn_func,
)
except ImportError as exc: # pragma: no cover - optional dependency
raise ImportError(_FA4_IMPORT_HINT) from exc
return BlockSparseTensorsTorch, _flash_attn_fwd
return BlockSparseTensorsTorch, flash_attn_func, _flash_attn_fwd, _flash_attn_bwd
# Q-side tile size; kv_block_size comes from the caller's VSA logical KV block.
_M_BLOCK_SIZE_DEFAULT = 128
# FA4's physical Q tile size; KV block size comes from the VSA caller.
_FA4_Q_BLOCK_SIZE = 128
class _SingleQStageLength(int):
"""Keep the real length while selecting FA4's one-stage Q128 path.
On sm_100 FA4 derives ``q_stage`` from ``max_seqlen_q > tile_m``. Its
kernel supports one 128-token Q stage, but the fixed-length public wrapper
does not expose that choice. VSA-128 must select it explicitly; otherwise
adjacent logical Q blocks are merged into a 256-token sparse block.
"""
def __mul__(self, other):
return type(self)(int(self) * int(other))
def __rmul__(self, other):
return type(self)(int(other) * int(self))
def __gt__(self, other):
if int(other) == _FA4_Q_BLOCK_SIZE:
return False
return int(self) > int(other)
def _map_to_index(block_map: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
@@ -64,12 +90,12 @@ def _map_to_index(block_map: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
return triton_map_to_index(block_map)
def _choose_q_sparse_block_size(q_len: int, m_block_size: int = _M_BLOCK_SIZE_DEFAULT) -> int:
# FA4 supports a doubled Q sparsity granularity on sm_100+ when q_len > m_block_size.
def _choose_q_sparse_block_size(q_len: int, q_tile_size: int = _FA4_Q_BLOCK_SIZE) -> int:
# FA4 supports a doubled Q sparsity granularity on sm_100+ when q_len > q_tile_size.
major, _ = torch.cuda.get_device_capability()
if major >= 10 and q_len > m_block_size:
return 2 * m_block_size
return m_block_size
if major >= 10 and q_len > q_tile_size:
return 2 * q_tile_size
return q_tile_size
def _aggregate_q_block_map(
@@ -134,23 +160,35 @@ def _build_vbs_mask_mod(kv_block_size: int):
return _vbs_mask_mod
def _cute_forward(
q_bshd: torch.Tensor,
k_bshd: torch.Tensor,
v_bshd: torch.Tensor,
def _build_sparse_tensors(
block_map: torch.Tensor,
variable_block_sizes: torch.Tensor,
*,
q_len: int,
q_block_size: int,
kv_block_size: int,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""Internal: FA4 CuTe BSA fwd with BSHD inputs."""
BlockSparseTensorsTorch, _flash_attn_fwd = _load_fa4_cute()
q_sparse_candidate = _choose_q_sparse_block_size(q_bshd.shape[1])
q_sparse_block_size = max(
q_block_size,
((q_sparse_candidate + q_block_size - 1) // q_block_size) * q_block_size,
)
need_backward: bool,
force_q_sparse_block_size: int | None = None,
) -> Tuple[object, object | None]:
"""Build the Q-owned forward and KV-owned backward sparse metadata.
``need_backward`` is False on inference-only calls: the backward metadata
is a pair of dense ``[B, H, kv_blocks, q_blocks]`` int32 index tensors that
FA4 keeps alive on its autograd ctx until backward runs, so building it
when nothing requires grad is pure overhead (~80 MiB per call at Wan-14B
720p shape).
"""
BlockSparseTensorsTorch, _, _, _ = _load_fa4_cute()
if force_q_sparse_block_size is None:
q_sparse_candidate = _choose_q_sparse_block_size(q_len)
q_sparse_block_size = max(
q_block_size,
((q_sparse_candidate + q_block_size - 1) // q_block_size) * q_block_size,
)
else:
q_sparse_block_size = force_q_sparse_block_size
if q_sparse_block_size < q_block_size or q_sparse_block_size % q_block_size != 0:
raise ValueError("force_q_sparse_block_size must be a positive multiple of q_block_size")
sparse_map = _aggregate_q_block_map(
block_map,
q_sparse_block_size=q_sparse_block_size,
@@ -158,35 +196,166 @@ def _cute_forward(
)
kv_full = (variable_block_sizes == kv_block_size).view(1, 1, 1, -1)
kv_partial = ((variable_block_sizes > 0) & (variable_block_sizes < kv_block_size)).view(1, 1, 1, -1)
full_map = sparse_map & kv_full
mask_map = sparse_map & kv_partial
full_block_idx, full_block_cnt = _map_to_index(full_map)
mask_block_idx, mask_block_cnt = _map_to_index(mask_map)
def from_maps(full_map: torch.Tensor, mask_map: torch.Tensor) -> object:
full_block_idx, full_block_cnt = _map_to_index(full_map.contiguous())
mask_block_idx, mask_block_cnt = _map_to_index(mask_map.contiguous())
return BlockSparseTensorsTorch(
full_block_cnt=full_block_cnt.to(torch.int32).contiguous(),
full_block_idx=full_block_idx.to(torch.int32).contiguous(),
mask_block_cnt=mask_block_cnt.to(torch.int32).contiguous(),
mask_block_idx=mask_block_idx.to(torch.int32).contiguous(),
block_size=(q_sparse_block_size, kv_block_size),
)
sparse_tensors = BlockSparseTensorsTorch(
full_block_cnt=full_block_cnt.to(torch.int32).contiguous(),
full_block_idx=full_block_idx.to(torch.int32).contiguous(),
mask_block_cnt=mask_block_cnt.to(torch.int32).contiguous(),
mask_block_idx=mask_block_idx.to(torch.int32).contiguous(),
block_size=(q_sparse_block_size, kv_block_size),
forward_sparse_tensors = from_maps(
sparse_map & kv_full,
sparse_map & kv_partial,
)
# _flash_attn_fwd returns (out, lse, p, row_max); keep the first two.
out, lse = _flash_attn_fwd(
if not need_backward:
return forward_sparse_tensors, None
# FA4 backward is KV-owned: for each physical KV tile, list the sparse
# query tiles that selected it. Full and partial KV tiles stay separate
# so the token-level validity mask only runs for padded tiles.
backward_sparse_tensors = from_maps(
(sparse_map & kv_full).transpose(2, 3),
(sparse_map & kv_partial).transpose(2, 3),
)
return forward_sparse_tensors, backward_sparse_tensors
def _cute_attention_q128_forward(
q_bshd: torch.Tensor,
k_bshd: torch.Tensor,
v_bshd: torch.Tensor,
block_map: torch.Tensor,
variable_block_sizes: torch.Tensor,
*,
need_backward: bool,
) -> Tuple[torch.Tensor, torch.Tensor, object | None]:
"""Run FA4 with one physical Q stage per logical VSA-128 block."""
_, _, flash_attn_fwd, _ = _load_fa4_cute()
forward_sparse_tensors, backward_sparse_tensors = _build_sparse_tensors(
block_map,
variable_block_sizes,
q_len=q_bshd.shape[1],
q_block_size=_FA4_Q_BLOCK_SIZE,
kv_block_size=_FA4_Q_BLOCK_SIZE,
need_backward=need_backward,
force_q_sparse_block_size=_FA4_Q_BLOCK_SIZE,
)
out, lse = flash_attn_fwd(
q_bshd,
k_bshd,
v_bshd,
tile_mn=(_M_BLOCK_SIZE_DEFAULT, kv_block_size),
mask_mod=_build_vbs_mask_mod(kv_block_size),
block_sparse_tensors=sparse_tensors,
tile_mn=(_FA4_Q_BLOCK_SIZE, _FA4_Q_BLOCK_SIZE),
max_seqlen_q=_SingleQStageLength(q_bshd.shape[1]),
mask_mod=_build_vbs_mask_mod(_FA4_Q_BLOCK_SIZE),
block_sparse_tensors=forward_sparse_tensors,
aux_tensors=[variable_block_sizes],
causal=False,
return_lse=True,
)[:2]
return out, lse, backward_sparse_tensors
class _CuteAttentionQ128(torch.autograd.Function):
@staticmethod
def forward(ctx, q_bshd, k_bshd, v_bshd, block_map, variable_block_sizes):
out, lse, backward_sparse_tensors = _cute_attention_q128_forward(
q_bshd,
k_bshd,
v_bshd,
block_map,
variable_block_sizes,
need_backward=True,
)
ctx.save_for_backward(q_bshd, k_bshd, v_bshd, out, lse, variable_block_sizes)
ctx.backward_sparse_tensors = backward_sparse_tensors
ctx.mark_non_differentiable(lse)
ctx.set_materialize_grads(False)
return out, lse
@staticmethod
def backward(ctx, grad_out, grad_lse):
del grad_lse
q_bshd, k_bshd, v_bshd, out, lse, variable_block_sizes = ctx.saved_tensors
if grad_out is None:
grad_out = torch.zeros_like(out)
_, _, _, flash_attn_bwd = _load_fa4_cute()
dq, dk, dv = flash_attn_bwd(
q_bshd,
k_bshd,
v_bshd,
out,
grad_out.contiguous(),
lse,
softmax_scale=q_bshd.shape[-1]**-0.5,
mask_mod=_build_vbs_mask_mod(_FA4_Q_BLOCK_SIZE),
aux_tensors=[variable_block_sizes],
block_sparse_tensors=ctx.backward_sparse_tensors,
)
return dq, dk, dv, None, None
def _cute_attention_q128(
q_bshd: torch.Tensor,
k_bshd: torch.Tensor,
v_bshd: torch.Tensor,
block_map: torch.Tensor,
variable_block_sizes: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
need_backward = torch.is_grad_enabled() and any(t.requires_grad for t in (q_bshd, k_bshd, v_bshd))
if need_backward:
return _CuteAttentionQ128.apply(q_bshd, k_bshd, v_bshd, block_map, variable_block_sizes)
out, lse, _ = _cute_attention_q128_forward(
q_bshd,
k_bshd,
v_bshd,
block_map,
variable_block_sizes,
need_backward=False,
)
return out, lse
def _cute_attention(
q_bshd: torch.Tensor,
k_bshd: torch.Tensor,
v_bshd: torch.Tensor,
block_map: torch.Tensor,
variable_block_sizes: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""Run FA4's autograd-enabled block-sparse attention with BSHD inputs."""
_, flash_attn_func, _, _ = _load_fa4_cute()
q_block_size = q_bshd.shape[1] // block_map.shape[2]
kv_block_size = k_bshd.shape[1] // block_map.shape[3]
if q_block_size == kv_block_size == _FA4_Q_BLOCK_SIZE:
return _cute_attention_q128(q_bshd, k_bshd, v_bshd, block_map, variable_block_sizes)
need_backward = torch.is_grad_enabled() and any(t.requires_grad for t in (q_bshd, k_bshd, v_bshd))
forward_sparse_tensors, backward_sparse_tensors = _build_sparse_tensors(
block_map,
variable_block_sizes,
q_len=q_bshd.shape[1],
q_block_size=q_block_size,
kv_block_size=kv_block_size,
need_backward=need_backward,
)
return flash_attn_func(
q_bshd,
k_bshd,
v_bshd,
mask_mod=_build_vbs_mask_mod(kv_block_size),
aux_tensors=[variable_block_sizes],
block_sparse_tensors=forward_sparse_tensors,
block_sparse_tensors_bwd=backward_sparse_tensors,
return_lse=True,
)
def block_sparse_attn_cute_fwd(
q: torch.Tensor,
k: torch.Tensor,
@@ -194,34 +363,25 @@ def block_sparse_attn_cute_fwd(
block_map: torch.Tensor,
variable_block_sizes: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""CuTe forward-only block-sparse attention with [B, H, S, D] inputs."""
"""Autograd-enabled CuTe block-sparse attention for [B, H, S, D]."""
if block_map.dim() == 3:
block_map = block_map.unsqueeze(0)
q_block_size = q.shape[2] // block_map.shape[2]
kv_block_size = k.shape[2] // block_map.shape[3]
q_bshd = q.transpose(1, 2).contiguous()
k_bshd = k.transpose(1, 2).contiguous()
v_bshd = v.transpose(1, 2).contiguous()
out_bshd, lse_bshd = _cute_forward(
out_bshd, lse = _cute_attention(
q_bshd,
k_bshd,
v_bshd,
block_map,
variable_block_sizes,
q_block_size=q_block_size,
kv_block_size=kv_block_size,
)
out = out_bshd.transpose(1, 2).contiguous()
if lse_bshd is None:
lse = torch.empty(
(q.shape[0], q.shape[1], q.shape[2]),
dtype=torch.float32,
device=q.device,
)
else:
lse = lse_bshd.transpose(1, 2).contiguous()
return out, lse
# FA4 already returns lse as [B, H, S], matching the Triton path's aux
# contract, so it needs no transpose. Detach before any further op: the
# value is informational and callers never backprop through it.
return out, lse.detach()
def block_sparse_attn_cute_fwd_bshd(
@@ -231,27 +391,16 @@ def block_sparse_attn_cute_fwd_bshd(
block_map: torch.Tensor,
variable_block_sizes: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""CuTe forward-only block-sparse attention with [B, S, H, D] inputs."""
"""Autograd-enabled CuTe block-sparse attention for [B, S, H, D]."""
if block_map.dim() == 3:
block_map = block_map.unsqueeze(0)
q_block_size = q.shape[1] // block_map.shape[2]
kv_block_size = k.shape[1] // block_map.shape[3]
out, lse_bshd = _cute_forward(
out, lse = _cute_attention(
q,
k,
v,
block_map,
variable_block_sizes,
q_block_size=q_block_size,
kv_block_size=kv_block_size,
)
if lse_bshd is None:
lse = torch.empty(
(q.shape[0], q.shape[2], q.shape[1]),
dtype=torch.float32,
device=q.device,
)
else:
lse = lse_bshd.transpose(1, 2).contiguous()
return out, lse
# lse is [B, H, S] regardless of the q/k/v layout; see above.
return out, lse.detach()
+24 -22
View File
@@ -2,6 +2,8 @@ import math
import torch
from .block_sparse_attn import block_sparse_attn
from .block_sparse_attn_256 import (
block_sparse_attn_128,
block_sparse_attn_128_bshd,
block_sparse_attn_256,
block_sparse_attn_256_bshd,
)
@@ -74,12 +76,13 @@ def video_sparse_attn(
Dispatches the sparse branch by ``block_elements = prod(block_size)``:
- 64 -> existing TK/Triton path (see ``block_sparse_attn_from_indices``).
- 128 -> Triton fallback or CuTe FA4 block-sparse attention.
- 256 -> CuTe FA4 block-sparse attention (see ``block_sparse_attn_256``).
Backend overrides:
- ``FASTVIDEO_VSA_TRITON=1`` forces Triton in either path.
- ``FASTVIDEO_VSA_TK=1`` prefers the sm_90 TK kernel in the 64-block path.
- ``FASTVIDEO_VSA_CUTEDSL=1`` prefers CuTe in the 256-block path.
- ``FASTVIDEO_VSA_CUTEDSL=1`` prefers CuTe in the 128/256-block paths.
"""
if isinstance(block_size, int):
block_size = (block_size, block_size, block_size)
@@ -119,8 +122,9 @@ def video_sparse_attn(
# Sparse branch (fused Triton topk mask)
mask = fused_topk_mask(scores, topk)
if block_elements == 256:
out_s = block_sparse_attn_256(q, k, v, mask, variable_block_sizes)[0]
if block_elements in (128, 256):
attention = block_sparse_attn_128 if block_elements == 128 else block_sparse_attn_256
out_s = attention(q, k, v, mask, variable_block_sizes)[0]
else:
out_s = block_sparse_attn(q, k, v, mask, variable_block_sizes)[0]
@@ -142,14 +146,14 @@ def video_sparse_attn_bshd(
"""VSA entrypoint for [B, S, H, D] tensors.
Avoids the BHSD<->BSHD round-trip that ``video_sparse_attn`` performs on
the CuTe 256-block path; the 64-block path still expects BHSD and is not
the CuTe 128/256-block paths; the 64-block path still expects BHSD and is not
supported here.
"""
if isinstance(block_size, int):
block_size = (block_size, block_size, block_size)
block_elements = block_size[0] * block_size[1] * block_size[2]
if block_elements != 256:
raise ValueError("video_sparse_attn_bshd is only defined for block_elements=256 "
if block_elements not in (128, 256):
raise ValueError("video_sparse_attn_bshd is only defined for block_elements=128 or 256 "
f"(got {block_elements}); use video_sparse_attn for the 64-block path.")
batch, q_seq_len, heads, dim = q.shape
@@ -171,19 +175,15 @@ def video_sparse_attn_bshd(
raise ValueError(f"q_variable_block_sizes must have length q_num_blocks={q_num_blocks}, "
f"got {q_variable_block_sizes.numel()}")
# Compression branch (BSHD-native: mean over the 256-token axis).
token_idx = torch.arange(block_elements, device=q.device, dtype=torch.int32)
q_token_valid = (token_idx.view(1, -1) < q_variable_block_sizes.view(-1,
1)).view(1, q_num_blocks, block_elements, 1, 1)
kv_token_valid = (token_idx.view(1, -1) < variable_block_sizes.view(-1,
1)).view(1, kv_num_blocks, block_elements, 1, 1)
# Compression branch (BSHD-native: match fused_block_mean's semantics).
# Padding values are expected to be zero; gradients are broadcast across
# the full padded block, just like the BHSD fused common path.
q_c = q.view(batch, q_num_blocks, block_elements, heads, dim)
k_c = k.view(batch, kv_num_blocks, block_elements, heads, dim)
v_c = v.view(batch, kv_num_blocks, block_elements, heads, dim)
q_c = ((q_c.float() * q_token_valid).sum(dim=2) / q_variable_block_sizes.view(1, -1, 1, 1)).to(q.dtype)
k_c = ((k_c.float() * kv_token_valid).sum(dim=2) / variable_block_sizes.view(1, -1, 1, 1)).to(k.dtype)
v_c = ((v_c.float() * kv_token_valid).sum(dim=2) / variable_block_sizes.view(1, -1, 1, 1)).to(v.dtype)
q_c = (q_c.float().sum(dim=2) / q_variable_block_sizes.view(1, -1, 1, 1)).to(q.dtype)
k_c = (k_c.float().sum(dim=2) / variable_block_sizes.view(1, -1, 1, 1)).to(k.dtype)
v_c = (v_c.float().sum(dim=2) / variable_block_sizes.view(1, -1, 1, 1)).to(v.dtype)
q_ch = q_c.permute(0, 2, 1, 3).contiguous()
k_ch = k_c.permute(0, 2, 1, 3).contiguous()
v_ch = v_c.permute(0, 2, 1, 3).contiguous()
@@ -195,13 +195,15 @@ def video_sparse_attn_bshd(
# Sparse branch (fused Triton topk mask + CuTe BSHD).
mask = fused_topk_mask(scores, topk)
out_s, _ = block_sparse_attn_256_bshd(q, k, v, mask, variable_block_sizes)
attention = block_sparse_attn_128_bshd if block_elements == 128 else block_sparse_attn_256_bshd
out_s, _ = attention(q, k, v, mask, variable_block_sizes)
out = out_s
out_view = out.view(batch, q_num_blocks, block_elements, heads, dim)
# Out-of-place: ``out_s`` is the tensor FA4's autograd node saved for its
# backward, so mutating it in place invalidates the graph.
out_view = out_s.view(batch, q_num_blocks, block_elements, heads, dim)
if compress_attn_weight is not None:
gate_view = compress_attn_weight.view(batch, q_num_blocks, block_elements, heads, dim)
out_view.add_(out_c_blk.unsqueeze(2) * gate_view)
out = out_view + out_c_blk.unsqueeze(2) * gate_view
else:
out_view.add_(out_c_blk.unsqueeze(2))
return out
out = out_view + out_c_blk.unsqueeze(2)
return out.view(batch, q_seq_len, heads, dim)
@@ -0,0 +1,146 @@
"""VSA-128 CuTe/Triton forward and backward parity on Blackwell."""
from __future__ import annotations
import math
import pytest
import torch
from fastvideo_kernel import video_sparse_attn, video_sparse_attn_bshd
from fastvideo_kernel.block_sparse_attn_256 import block_sparse_attn_128
from .test_vsa256_triton import _metrics, _torch_vsa256_reference
_BLOCK = 128
_BLOCK_SIZE_3D = (2, 8, 8)
def _select_backend(monkeypatch, backend: str) -> None:
if not torch.cuda.is_available():
pytest.skip("CUDA is required")
if backend == "cute":
pytest.importorskip(
"flash_attn.cute.block_sparsity",
reason="optional FA4 CuTe build (flash_attn.cute) not installed",
)
monkeypatch.setenv("FASTVIDEO_VSA_CUTEDSL", "1")
monkeypatch.delenv("FASTVIDEO_VSA_TRITON", raising=False)
monkeypatch.delenv("FASTVIDEO_KERNEL_VSA_FORCE_TRITON", raising=False)
else:
monkeypatch.setenv("FASTVIDEO_VSA_TRITON", "1")
monkeypatch.delenv("FASTVIDEO_VSA_CUTEDSL", raising=False)
def _dense_sparse_reference(q, k, v, block_map, variable_block_sizes):
token_mask = block_map.repeat_interleave(_BLOCK, dim=2).repeat_interleave(_BLOCK, dim=3)
kv_valid = torch.arange(_BLOCK, device=k.device) < variable_block_sizes[:, None]
token_mask = token_mask & kv_valid.reshape(1, 1, 1, -1)
logits = torch.matmul(q.float(), k.float().transpose(-2, -1)) / math.sqrt(q.shape[-1])
probabilities = torch.softmax(logits.masked_fill(~token_mask, float("-inf")), dim=-1)
return torch.matmul(probabilities, v.float()).to(q.dtype)
def _check(tag: str, expected: torch.Tensor, actual: torch.Tensor, avg_tol: float, rel_tol: float) -> None:
assert torch.isfinite(actual).all().item(), f"{tag}: non-finite values"
avg_abs, max_rel = _metrics(expected, actual)
print(f" {tag}: avg_abs={avg_abs:.6e}, max_rel={max_rel:.6e}")
assert avg_abs < avg_tol
assert max_rel < rel_tol
@pytest.mark.cuda
@pytest.mark.parametrize("backend", ["cute", "triton"])
def test_vsa128_explicit_routes_forward_backward(backend: str, monkeypatch) -> None:
"""Adjacent Q128 blocks must keep independent routes instead of merging."""
_select_backend(monkeypatch, backend)
torch.manual_seed(53)
shape = (1, 1, 3 * _BLOCK, 128)
base = [torch.randn(shape, device="cuda", dtype=torch.bfloat16) for _ in range(3)]
grad_output = torch.randn(shape, device="cuda", dtype=torch.bfloat16)
variable_block_sizes = torch.tensor([128, 91, 37], device="cuda", dtype=torch.int32)
block_map = torch.eye(3, device="cuda", dtype=torch.bool).view(1, 1, 3, 3)
actual_inputs = [tensor.detach().clone().requires_grad_() for tensor in base]
actual, _ = block_sparse_attn_128(*actual_inputs, block_map, variable_block_sizes)
(actual * grad_output).sum().backward()
reference_inputs = [tensor.detach().clone().requires_grad_() for tensor in base]
expected = _dense_sparse_reference(*reference_inputs, block_map, variable_block_sizes)
(expected * grad_output).sum().backward()
print(f"[vsa128-explicit-{backend}]")
_check("out", expected, actual, 1e-3, 0.2)
for name, reference, candidate in zip(("dq", "dk", "dv"), reference_inputs, actual_inputs, strict=True):
_check(name, reference.grad, candidate.grad, 2e-2, 0.5)
def _zero_kv_tail(x: torch.Tensor, variable_block_sizes: torch.Tensor) -> torch.Tensor:
valid = torch.arange(_BLOCK, device=x.device) < variable_block_sizes[:, None]
valid = valid.view(1, 1, -1, _BLOCK, 1).expand_as(x.view(1, x.shape[1], -1, _BLOCK, x.shape[-1]))
return x * valid.reshape_as(x).to(x.dtype)
@pytest.mark.cuda
@pytest.mark.parametrize("backend", ["cute", "triton"])
@pytest.mark.parametrize("layout", ["bhsd", "bshd"])
def test_vsa128_wrapper_forward_backward(backend: str, layout: str, monkeypatch) -> None:
_select_backend(monkeypatch, backend)
torch.manual_seed(59)
batch, heads, dim = 1, 2, 128
q_blocks, kv_blocks, topk = 3, 4, 2
q_shape = (batch, heads, q_blocks * _BLOCK, dim)
kv_shape = (batch, heads, kv_blocks * _BLOCK, dim)
q_base = torch.randn(q_shape, device="cuda", dtype=torch.bfloat16)
kv_sizes = torch.tensor([128, 91, 37, 128], device="cuda", dtype=torch.int32)
q_sizes = torch.full((q_blocks, ), _BLOCK, device="cuda", dtype=torch.int32)
k_base = _zero_kv_tail(torch.randn(kv_shape, device="cuda", dtype=torch.bfloat16), kv_sizes)
v_base = _zero_kv_tail(torch.randn(kv_shape, device="cuda", dtype=torch.bfloat16), kv_sizes)
gate_base = torch.randn(q_shape, device="cuda", dtype=torch.bfloat16) * 0.1
grad_output = torch.randn(q_shape, device="cuda", dtype=torch.bfloat16)
if layout == "bhsd":
actual_inputs = [tensor.detach().clone().requires_grad_() for tensor in (q_base, k_base, v_base)]
actual_gate = gate_base.detach().clone().requires_grad_()
actual = video_sparse_attn(
*actual_inputs,
kv_sizes,
q_sizes,
topk,
block_size=_BLOCK_SIZE_3D,
compress_attn_weight=actual_gate,
)
actual_grads = actual_inputs
else:
bshd_inputs = [tensor.transpose(1, 2).contiguous().detach().requires_grad_()
for tensor in (q_base, k_base, v_base)]
bshd_gate = gate_base.transpose(1, 2).contiguous().detach().requires_grad_()
actual = video_sparse_attn_bshd(
*bshd_inputs,
kv_sizes,
q_sizes,
topk,
block_size=_BLOCK_SIZE_3D,
compress_attn_weight=bshd_gate,
).transpose(1, 2)
actual_grads = bshd_inputs
(actual * grad_output).sum().backward()
reference_inputs = [tensor.detach().clone().requires_grad_() for tensor in (q_base, k_base, v_base)]
reference_gate = gate_base.detach().clone().requires_grad_()
expected = _torch_vsa256_reference(
*reference_inputs,
q_sizes,
kv_sizes,
topk,
compress_attn_weight=reference_gate,
)
(expected * grad_output).sum().backward()
print(f"[vsa128-wrapper-{backend}-{layout}]")
_check("out", expected, actual, 1e-3, 0.2)
for name, reference, candidate in zip(("dq", "dk", "dv"), reference_inputs, actual_grads, strict=True):
candidate_grad = candidate.grad if layout == "bhsd" else candidate.grad.transpose(1, 2)
_check(name, reference.grad, candidate_grad, 2e-2, 0.5)
actual_gate_grad = actual_gate.grad if layout == "bhsd" else bshd_gate.grad.transpose(1, 2)
_check("dgate", reference_gate.grad, actual_gate_grad, 1e-3, 0.2)
@@ -0,0 +1,224 @@
"""VSA-256 FA4 CuTe forward/backward parity for BHSD and BSHD APIs.
Covers the shapes the CuTe backward actually sees in production: the gated
compression branch (`compress_attn_weight`), partially filled Q tiles,
and q_len != kv_len. Also pins the inference fast path, which must skip the
KV-owned backward metadata without changing the forward result.
"""
from __future__ import annotations
from typing import Tuple
import pytest
import torch
from fastvideo_kernel import video_sparse_attn, video_sparse_attn_bshd
from .test_vsa256_triton import _metrics, _torch_vsa256_reference
_BLOCK = 256
_BLOCK_SIZE_3D = (4, 8, 8) # prod == 256
# Measured on GB200 (sm_100) with bf16 inputs: grads land around 1e-4 avg_abs
# and <=0.11 max_rel across every case below, so these leave ~10x headroom
# without being loose enough to hide a real regression.
_OUT_TOL = (1e-3, 0.2)
_GRAD_TOL = (1e-3, 0.25)
@pytest.fixture(autouse=True)
def _require_cute_backend(monkeypatch):
pytest.importorskip(
"flash_attn.cute.block_sparsity",
reason="optional FA4 CuTe build (flash_attn.cute) not installed",
)
if not torch.cuda.is_available():
pytest.skip("CUDA is required")
monkeypatch.setenv("FASTVIDEO_VSA_CUTEDSL", "1")
monkeypatch.delenv("FASTVIDEO_VSA_TRITON", raising=False)
monkeypatch.delenv("FASTVIDEO_KERNEL_VSA_FORCE_TRITON", raising=False)
def _zero_pad_tail(x: torch.Tensor, var: torch.Tensor) -> torch.Tensor:
"""Zero the padded tail of every 256-token tile of a [B, H, S, D] tensor.
VSA callers scatter into a zeroed tile buffer, so padded slots are zero;
both the kernel and the reference rely on that.
"""
bsz, heads, _, dim = x.shape
blocks = var.numel()
token_idx = torch.arange(_BLOCK, device=x.device, dtype=torch.int32)
valid = (token_idx.view(1, -1) < var.view(-1, 1)).view(1, 1, blocks, _BLOCK, 1)
valid = valid.expand(bsz, heads, blocks, _BLOCK, dim).reshape_as(x)
return x * valid.to(x.dtype)
def _make_inputs(
q_blocks: int,
kv_blocks: int,
kv_var: torch.Tensor,
q_var: torch.Tensor,
heads: int = 2,
dim: int = 128,
seed: int = 42,
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
torch.manual_seed(seed)
device = torch.device("cuda")
dtype = torch.bfloat16
sq, skv = q_blocks * _BLOCK, kv_blocks * _BLOCK
q = torch.randn(1, heads, sq, dim, device=device, dtype=dtype)
k = torch.randn(1, heads, skv, dim, device=device, dtype=dtype)
v = torch.randn(1, heads, skv, dim, device=device, dtype=dtype)
grad_out = torch.randn_like(q)
return _zero_pad_tail(q, q_var), _zero_pad_tail(k, kv_var), _zero_pad_tail(v, kv_var), grad_out
def _check(tag: str, ref: torch.Tensor, got: torch.Tensor, tol: Tuple[float, float]) -> None:
assert torch.isfinite(got).all().item(), f"{tag}: non-finite values"
avg_abs, max_rel = _metrics(ref, got)
print(f" {tag}: avg_abs={avg_abs:.6e}, max_rel={max_rel:.6e}")
assert avg_abs < tol[0], f"{tag}: avg_abs {avg_abs:.3e} >= {tol[0]:.3e}"
assert max_rel < tol[1], f"{tag}: max_rel {max_rel:.3e} >= {tol[1]:.3e}"
def _run_bhsd(q, k, v, kv_var, q_var, topk, gate=None):
qg, kg, vg = (t.detach().clone().requires_grad_(True) for t in (q, k, v))
out = video_sparse_attn(qg, kg, vg, kv_var, q_var, topk, block_size=_BLOCK_SIZE_3D, compress_attn_weight=gate)
return out, (qg, kg, vg)
def _run_bshd(q, k, v, kv_var, q_var, topk, gate=None):
qg, kg, vg = (t.transpose(1, 2).contiguous().requires_grad_(True) for t in (q, k, v))
gate_bshd = None if gate is None else gate.transpose(1, 2).contiguous()
out = video_sparse_attn_bshd(qg,
kg,
vg,
kv_var,
q_var,
topk,
block_size=_BLOCK_SIZE_3D,
compress_attn_weight=gate_bshd)
return out.transpose(1, 2), (qg, kg, vg)
def _reference(q, k, v, q_var, kv_var, topk, gate=None):
qr, kr, vr = (t.detach().clone().requires_grad_(True) for t in (q, k, v))
out = _torch_vsa256_reference(qr, kr, vr, q_var, kv_var, topk, compress_attn_weight=gate)
return out, (qr, kr, vr)
def _compare(tag, layout, q, k, v, kv_var, q_var, topk, grad_out, gate=None):
runner = _run_bhsd if layout == "bhsd" else _run_bshd
out, (qg, kg, vg) = runner(q, k, v, kv_var, q_var, topk, gate=gate)
(out * grad_out).sum().backward()
grads = [g.grad if g.grad.dim() == 4 and layout == "bhsd" else g.grad for g in (qg, kg, vg)]
if layout == "bshd":
grads = [g.transpose(1, 2) for g in grads]
out_ref, refs = _reference(q, k, v, q_var, kv_var, topk, gate=gate)
(out_ref * grad_out).sum().backward()
print(f"[{tag}-{layout}]")
_check("out", out_ref, out, _OUT_TOL)
for name, ref, got in zip(("dq", "dk", "dv"), refs, grads):
_check(name, ref.grad, got, _GRAD_TOL)
@pytest.mark.cuda
@pytest.mark.parametrize("layout", ["bhsd", "bshd"])
def test_vsa256_cute_forward_backward_vs_torch_ref(layout: str) -> None:
kv_var = torch.tensor([256, 173, 79, 256], dtype=torch.int32, device="cuda")
q_var = torch.full((3, ), _BLOCK, dtype=torch.int32, device="cuda")
q, k, v, grad_out = _make_inputs(3, 4, kv_var, q_var)
_compare("vsa256-cute", layout, q, k, v, kv_var, q_var, 2, grad_out)
@pytest.mark.cuda
@pytest.mark.parametrize("layout", ["bhsd", "bshd"])
def test_vsa256_cute_backward_with_compress_gate(layout: str) -> None:
"""The gated compression branch is what Wan and MiniMax-H3 actually run.
It is also the branch that composes the sparse output with the compression
output, so it is the one that breaks if that composition mutates FA4's
saved output in place.
"""
kv_var = torch.tensor([256, 200, 256, 91], dtype=torch.int32, device="cuda")
q_var = torch.full((3, ), _BLOCK, dtype=torch.int32, device="cuda")
q, k, v, grad_out = _make_inputs(3, 4, kv_var, q_var, seed=7)
gate = torch.randn_like(q) * 0.1
_compare("vsa256-cute-gated", layout, q, k, v, kv_var, q_var, 2, grad_out, gate=gate)
@pytest.mark.cuda
@pytest.mark.parametrize("layout", ["bhsd", "bshd"])
def test_vsa256_cute_backward_partial_q_blocks(layout: str) -> None:
"""Q tiles that are not full: only the compression divisor depends on it,
but it is the one axis the existing coverage held constant."""
kv_var = torch.tensor([256, 128, 256], dtype=torch.int32, device="cuda")
q_var = torch.tensor([256, 61, 199], dtype=torch.int32, device="cuda")
q, k, v, grad_out = _make_inputs(3, 3, kv_var, q_var, seed=11)
_compare("vsa256-cute-partial-q", layout, q, k, v, kv_var, q_var, 2, grad_out)
@pytest.mark.cuda
@pytest.mark.parametrize("layout", ["bhsd", "bshd"])
def test_vsa256_cute_backward_cross_q_kv(layout: str) -> None:
"""q_len != kv_len: forward has coverage, backward did not."""
kv_var = torch.tensor([256, 143, 256, 256, 88], dtype=torch.int32, device="cuda")
q_var = torch.full((2, ), _BLOCK, dtype=torch.int32, device="cuda")
q, k, v, grad_out = _make_inputs(2, 5, kv_var, q_var, seed=13)
_compare("vsa256-cute-cross", layout, q, k, v, kv_var, q_var, 3, grad_out)
@pytest.mark.cuda
def test_vsa256_cute_inference_matches_training_forward() -> None:
"""The KV-owned backward metadata is only built when something requires
grad. Skipping it must not perturb the forward result."""
kv_var = torch.tensor([256, 173, 79, 256], dtype=torch.int32, device="cuda")
q_var = torch.full((3, ), _BLOCK, dtype=torch.int32, device="cuda")
q, k, v, _ = _make_inputs(3, 4, kv_var, q_var, seed=5)
with torch.no_grad():
out_infer = video_sparse_attn_bshd(
q.transpose(1, 2).contiguous(),
k.transpose(1, 2).contiguous(),
v.transpose(1, 2).contiguous(),
kv_var,
q_var,
2,
block_size=_BLOCK_SIZE_3D,
compress_attn_weight=None,
)
out_train, _ = _run_bshd(q, k, v, kv_var, q_var, 2)
torch.testing.assert_close(out_infer, out_train.transpose(1, 2).detach(), rtol=0, atol=0)
@pytest.mark.cuda
def test_vsa256_cute_lse_is_bhs() -> None:
"""The aux return is [B, H, S] on both entrypoints, matching the Triton
path's contract."""
from fastvideo_kernel.block_sparse_attn_256 import (block_sparse_attn_256, block_sparse_attn_256_bshd)
device = torch.device("cuda")
heads, dim, q_blocks, kv_blocks = 2, 128, 3, 4
sq, skv = q_blocks * _BLOCK, kv_blocks * _BLOCK
q = torch.randn(1, heads, sq, dim, device=device, dtype=torch.bfloat16)
k = torch.randn(1, heads, skv, dim, device=device, dtype=torch.bfloat16)
v = torch.randn(1, heads, skv, dim, device=device, dtype=torch.bfloat16)
vbs = torch.full((kv_blocks, ), _BLOCK, dtype=torch.int32, device=device)
mask = torch.zeros(1, heads, q_blocks, kv_blocks, dtype=torch.bool, device=device)
mask[..., :2] = True
_, lse_bhsd = block_sparse_attn_256(q, k, v, mask, vbs)
assert lse_bhsd.shape == (1, heads, sq), lse_bhsd.shape
_, lse_bshd = block_sparse_attn_256_bshd(
q.transpose(1, 2).contiguous(),
k.transpose(1, 2).contiguous(),
v.transpose(1, 2).contiguous(),
mask,
vbs,
)
assert lse_bshd.shape == (1, heads, sq), lse_bshd.shape
@@ -22,6 +22,7 @@ def _torch_vsa256_reference(
q_var: torch.Tensor,
kv_var: torch.Tensor,
topk_logical: int,
compress_attn_weight: torch.Tensor | None = None,
) -> torch.Tensor:
bsz, heads, _sq, dim = q.shape
q_blocks = q_var.numel()
@@ -55,6 +56,8 @@ def _torch_vsa256_reference(
logits = logits.masked_fill(~token_mask, float("-inf"))
prob = torch.softmax(logits, dim=-1)
out_s = torch.matmul(prob, vf).to(q.dtype)
if compress_attn_weight is not None:
return out_c * compress_attn_weight + out_s
return out_c + out_s
+14
View File
@@ -23,6 +23,20 @@ from fastvideo_kernel.block_sparse_attn import (
from fastvideo_kernel.block_sparse_attn_varlen import block_sparse_attn_varlen
@pytest.fixture(autouse=True)
def _seed_rng():
"""Pin the RNG so these cases do not depend on what ran before them.
Every tensor and every variable block size here comes from the global
torch RNG, and the gradient checks use a tight max_rel threshold. Without
a seed the inputs shift whenever an earlier test file draws a different
number of randoms, which surfaces as an unrelated-looking failure in
whichever case happens to land on unlucky data.
"""
torch.manual_seed(0)
torch.cuda.manual_seed_all(0)
def _reference_per_sequence(
q_list,
k_list,
@@ -319,8 +319,13 @@ class MiniMaxH3VSAImpl(AttentionImpl):
v_pooled = _pool_tiles(value, attn_metadata.variable_block_sizes)
out_c = torch.matmul(torch.softmax(scores, dim=-1), v_pooled) # [B, H, n_tiles, D]
out_c = out_c.permute(0, 2, 1, 3).to(out.dtype) # [B, n_tiles, H, D]
batch, _, heads, dim = out.shape
batch, seq_len, heads, dim = out.shape
n_tiles = attn_metadata.variable_block_sizes.numel()
out.view(batch, n_tiles, _TILE_ELEMS, heads,
dim).addcmul_(out_c.unsqueeze(2), gate_compress.view(batch, n_tiles, _TILE_ELEMS, heads, dim))
# Out-of-place: on the CuTe backend ``out`` is the tensor FA4's
# autograd node saved for its backward, so an in-place add here
# bumps its version counter and backward dies with "one of the
# variables needed for gradient computation has been modified".
out_tiled = out.view(batch, n_tiles, _TILE_ELEMS, heads, dim)
gate_tiled = gate_compress.view(batch, n_tiles, _TILE_ELEMS, heads, dim)
out = (out_tiled + out_c.unsqueeze(2) * gate_tiled).view(batch, seq_len, heads, dim)
return out
+1
View File
@@ -0,0 +1 @@
# SPDX-License-Identifier: Apache-2.0
+375
View File
@@ -0,0 +1,375 @@
# SPDX-License-Identifier: Apache-2.0
"""Benchmark generate-fewer-frames plus MLX RIFE interpolation.
This script keeps the video diffusion path delegated to
``fastvideo.benchmarks.mlx_fastwan_bench._generate_cell``. It only orchestrates
two frame-count cells and the postprocess interpolation step.
"""
from __future__ import annotations
import argparse
import json
import shutil
import time
from pathlib import Path
from types import SimpleNamespace
import imageio.v2 as imageio
import numpy as np
from examples.inference.basic.mlx_wan_prompt_to_video import encode_prompt, make_rotary_embeddings
from fastvideo.benchmarks.mlx_fastwan_bench import _generate_cell, _ms_ssim
from fastvideo.mlx_runtime.rife_interp import RIFEBackendError, interpolate, load_model
from fastvideo.mlx_runtime.memory import add_memory_limit_args, apply_memory_limits
FOX_PROMPT = "A fox runs through a misty pine forest, leaves kicking up behind it."
DEFAULT_MODEL_ROOT = Path("/Users/aryank/models/qad_int8_v2")
def _parse_timesteps(raw: str) -> list[int]:
timesteps = [int(part.strip()) for part in raw.split(",") if part.strip()]
if not timesteps:
raise SystemExit("No DMD timesteps parsed from --dmd-denoising-steps")
return timesteps
def _read_video(path: Path) -> list[np.ndarray]:
if not path.is_file():
raise FileNotFoundError(f"Video does not exist: {path}")
frames = [np.asarray(frame[:, :, :3], dtype=np.uint8) for frame in imageio.mimread(path)]
if not frames:
raise RuntimeError(f"No frames decoded from {path}")
return frames
def _write_video(path: Path, frames: list[np.ndarray], fps: int) -> None:
if not frames:
raise ValueError("Cannot write an empty frame list")
path.parent.mkdir(parents=True, exist_ok=True)
with imageio.get_writer(str(path), fps=fps, macro_block_size=1, codec="libx264", quality=8) as writer:
for frame in frames:
writer.append_data(np.asarray(frame, dtype=np.uint8))
def _copy_video(src: Path, dst: Path) -> Path:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
return dst
def _make_generation_inputs(args, num_frames: int):
import mlx.core as mx
import torch
config_path = args.model_root / "transformer" / "config.json"
checkpoint_path = args.model_root / "transformer" / "diffusion_pytorch_model.safetensors"
if not config_path.is_file():
raise SystemExit(f"Missing DiT config: {config_path}")
if not checkpoint_path.is_file():
raise SystemExit(f"Missing DiT checkpoint: {checkpoint_path}")
config = json.loads(config_path.read_text())
latent_frames = (num_frames - 1) // 4 + 1
latent_height = args.height // 8
latent_width = args.width // 8
freqs_cis = make_rotary_embeddings(
config,
latent_frames=latent_frames,
latent_height=latent_height,
latent_width=latent_width,
)
generator = torch.Generator(device="cpu").manual_seed(args.seed)
latents_seed = torch.randn(
(1, int(config["in_channels"]), latent_frames, latent_height, latent_width),
generator=generator,
dtype=torch.float32,
).numpy()
timesteps = _parse_timesteps(args.dmd_denoising_steps)
renoise_by_step = [
torch.randn(latents_seed.shape, generator=generator, dtype=torch.float32).numpy()
for _ in range(max(0,
len(timesteps) - 1))
]
prompt_embeds = encode_prompt(
model_root=args.model_root,
prompt=args.prompt,
max_sequence_length=args.max_sequence_length,
device_arg=args.torch_device,
dtype_arg=args.torch_dtype,
)
return {
"checkpoint_path": checkpoint_path,
"config_path": config_path,
"encoder_hidden_states": mx.array(prompt_embeds.numpy()),
"freqs_cis": freqs_cis,
"timesteps": timesteps,
"latents_seed": latents_seed,
"renoise_by_step": renoise_by_step,
"latent_frames": latent_frames,
}
def _generate(args, num_frames: int):
cell_args = SimpleNamespace(
model_root=args.model_root,
height=args.height,
width=args.width,
num_frames=num_frames,
fps=args.fps,
flow_shift=args.flow_shift,
torch_device=args.torch_device,
torch_dtype=args.torch_dtype,
taehv_source_path=args.taehv_source_path,
taehv_checkpoint_path=args.taehv_checkpoint_path,
taehv_parallel=args.taehv_parallel,
mlx_checkpoint_cache=args.mlx_checkpoint_cache,
mlx_memory_limit_gib=args.mlx_memory_limit_gib,
mlx_cache_limit_gib=args.mlx_cache_limit_gib,
mlx_disable_cache=args.mlx_disable_cache,
mlx_wired_limit_gib=args.mlx_wired_limit_gib,
torch_mps_high_watermark_ratio=args.torch_mps_high_watermark_ratio,
torch_mps_low_watermark_ratio=args.torch_mps_low_watermark_ratio,
benchmark_preset="metalfx-rife",
current_prompt_id=f"fox-{num_frames}f",
current_prompt=args.prompt,
output_dir=args.output_dir,
)
inputs = _make_generation_inputs(args, num_frames)
print(f"=== generate: {num_frames} frames ({inputs['latent_frames']} latent frames) ===", flush=True)
return _generate_cell(
args=cell_args,
mode=args.mode,
decoder=args.decoder,
checkpoint_path=inputs["checkpoint_path"],
config_path=inputs["config_path"],
encoder_hidden_states=inputs["encoder_hidden_states"],
freqs_cis=inputs["freqs_cis"],
timesteps=inputs["timesteps"],
latents_seed=inputs["latents_seed"],
renoise_by_step=inputs["renoise_by_step"],
)
def _relative_to_output(path: Path, output_dir: Path) -> str:
try:
return str(path.relative_to(output_dir))
except ValueError:
return str(path)
def _write_report(args, result: dict) -> Path:
report_path = args.output_dir / "metalfx_rife_report.md"
rows = result["speed_rows"]
table_lines = [
"| path | denoise_s | decode_s | gen_total_s | rife_s | net_s | speedup_vs_81 |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: |",
]
for row in rows:
table_lines.append(
"| {path} | {denoise_s:.3f} | {decode_s:.3f} | {gen_total_s:.3f} | {rife_s:.3f} | {net_s:.3f} | {speedup_vs_81:.3f}x |"
.format(**row))
text = f"""# MetalFX/RIFE Generate-Fewer-Frames Benchmark Run
Prompt: {args.prompt}
Resolution: {args.height}x{args.width}, fps={args.fps}, mode={args.mode}, decoder={args.decoder}
| metric | value |
| --- | ---: |
| reconstruction_ms_ssim | {result['reconstruction_ms_ssim']:.6f} |
| reference_frames | {result['reference_frames']} |
| reduced_frames | {result['reduced_frames']} |
| interpolated_frames | {result['interpolated_frames']} |
{chr(10).join(table_lines)}
Videos:
- reference: `{result['videos']['reference']}`
- reference drop-41 RIFE reconstruction: `{result['videos']['drop41_rife81']}`
- generated 41 RIFE to 81: `{result['videos']['generated41_rife81']}`
- generated 41 direct: `{result['videos']['generated41']}`
Raw metrics are in `{_relative_to_output(args.output_dir / 'metrics.json', args.output_dir)}`.
"""
report_path.write_text(text)
return report_path
def main() -> None:
parser = argparse.ArgumentParser(
description="Evaluate 41-frame generation plus MLX RIFE interpolation vs 81-frame generation.")
parser.add_argument("--model-root", type=Path, default=DEFAULT_MODEL_ROOT)
parser.add_argument("--prompt", default=FOX_PROMPT)
parser.add_argument("--height", type=int, default=480)
parser.add_argument("--width", type=int, default=832)
parser.add_argument("--reference-frames", type=int, default=81)
parser.add_argument("--reduced-frames", type=int, default=41)
parser.add_argument("--fps", type=int, default=16)
parser.add_argument("--seed", type=int, default=1024)
parser.add_argument("--mode", default="int8", choices=("fp16", "bf16", "int8", "int4", "mxfp8", "mxfp4", "nvfp4"))
parser.add_argument("--decoder", default="taehv", choices=("taehv", "wan-vae"))
parser.add_argument("--dmd-denoising-steps", default="1000,757,522")
parser.add_argument("--flow-shift", type=float, default=8.0)
parser.add_argument("--max-sequence-length", type=int, default=512)
parser.add_argument("--torch-device", default="auto")
parser.add_argument("--torch-dtype", choices=("fp16", "fp32"), default="fp16")
parser.add_argument("--output-dir", type=Path, default=Path("bench/metalfx_rife"))
parser.add_argument("--mlx-checkpoint-cache", type=Path, default=None)
parser.add_argument("--compile", action="store_true", help="Enable FASTVIDEO_MLX_COMPILE=1 for DiT denoise.")
parser.add_argument("--rife-scale", type=float, default=1.0)
parser.add_argument("--taehv-source-path", type=Path, default=None)
parser.add_argument("--taehv-checkpoint-path", type=Path, default=None)
parser.add_argument("--taehv-parallel", action="store_true")
add_memory_limit_args(parser)
args = parser.parse_args()
if args.reference_frames != (args.reduced_frames - 1) * 2 + 1:
raise SystemExit(
"--reference-frames must equal (--reduced-frames - 1) * 2 + 1 for the default every-other-frame test")
if args.compile:
import os
os.environ["FASTVIDEO_MLX_COMPILE"] = "1"
args.model_root = args.model_root.expanduser().resolve()
args.output_dir = args.output_dir.expanduser().resolve()
args.output_dir.mkdir(parents=True, exist_ok=True)
if args.mlx_checkpoint_cache is None:
args.mlx_checkpoint_cache = args.output_dir / "mlx_checkpoint_cache"
else:
args.mlx_checkpoint_cache = args.mlx_checkpoint_cache.expanduser().resolve()
import mlx.core as mx
import torch
runtime_limits = apply_memory_limits(
mlx_memory_limit_gib=args.mlx_memory_limit_gib,
mlx_cache_limit_gib=args.mlx_cache_limit_gib,
mlx_disable_cache=args.mlx_disable_cache,
mlx_wired_limit_gib=args.mlx_wired_limit_gib,
torch_mps_high_watermark_ratio=args.torch_mps_high_watermark_ratio,
torch_mps_low_watermark_ratio=args.torch_mps_low_watermark_ratio,
mx_module=mx,
).as_metrics()
mx.random.seed(args.seed)
torch.manual_seed(args.seed)
reference_cell = _generate(args, args.reference_frames)
reduced_cell = _generate(args, args.reduced_frames)
reference_video = _copy_video(reference_cell.video_path, args.output_dir / "fox_reference_81.mp4")
generated41_video = _copy_video(reduced_cell.video_path, args.output_dir / "fox_generated_41.mp4")
reference_frames = _read_video(reference_video)
dropped_reference_frames = reference_frames[::2]
if len(dropped_reference_frames) != args.reduced_frames:
raise RuntimeError(f"Expected {args.reduced_frames} dropped frames, got {len(dropped_reference_frames)}")
try:
rife_model = load_model("4.25")
except RIFEBackendError:
raise
except Exception as exc: # noqa: BLE001 - preserve exact backend failure.
raise RIFEBackendError(f"Unexpected RIFE load failure: {exc}") from exc
print("=== RIFE reconstruction: drop every other reference frame, 41 -> 81 ===", flush=True)
start = time.perf_counter()
reconstructed_frames = interpolate(dropped_reference_frames, factor=2, model=rife_model, scale=args.rife_scale)
recon_rife_s = time.perf_counter() - start
reconstructed_video = args.output_dir / "fox_reference_drop41_rife81.mp4"
_write_video(reconstructed_video, reconstructed_frames, args.fps)
reconstruction_ms_ssim = _ms_ssim(reference_video, reconstructed_video, required=True)
if reconstruction_ms_ssim is None:
raise RuntimeError("MS-SSIM returned None for reconstruction comparison")
print("=== RIFE actual reduced path: generated 41 -> 81 ===", flush=True)
generated41_frames = _read_video(generated41_video)
start = time.perf_counter()
generated41_rife_frames = interpolate(generated41_frames, factor=2, model=rife_model, scale=args.rife_scale)
generated41_rife_s = time.perf_counter() - start
generated41_rife_video = args.output_dir / "fox_generated41_rife81.mp4"
_write_video(generated41_rife_video, generated41_rife_frames, args.fps)
direct_denoise_s = float(reference_cell.metrics["denoise_s"])
reduced_denoise_s = float(reduced_cell.metrics["denoise_s"])
direct_gen_total_s = float(reference_cell.metrics["denoise_s"]) + float(reference_cell.metrics["decode_s"])
reduced_gen_total_s = float(reduced_cell.metrics["denoise_s"]) + float(reduced_cell.metrics["decode_s"])
net_denoise_rife_s = reduced_denoise_s + generated41_rife_s
net_gen_rife_s = reduced_gen_total_s + generated41_rife_s
result = {
"prompt":
args.prompt,
"model_root":
str(args.model_root),
"rife_impl":
"rife-mlx vendored at fastvideo/third_party/rife_mlx, weights mlx-community/RIFE-4.25",
"runtime_limits":
runtime_limits,
"reference_frames":
len(reference_frames),
"reduced_frames":
len(generated41_frames),
"interpolated_frames":
len(generated41_rife_frames),
"reconstruction_ms_ssim":
reconstruction_ms_ssim,
"reconstruction_rife_s":
recon_rife_s,
"generated41_rife_s":
generated41_rife_s,
"reference_metrics":
reference_cell.metrics,
"reduced_metrics":
reduced_cell.metrics,
"speed_rows": [
{
"path": "generate_81",
"denoise_s": direct_denoise_s,
"decode_s": float(reference_cell.metrics["decode_s"]),
"gen_total_s": direct_gen_total_s,
"rife_s": 0.0,
"net_s": direct_gen_total_s,
"speedup_vs_81": 1.0,
},
{
"path": "generate_41_plus_rife81_denoise_only",
"denoise_s": reduced_denoise_s,
"decode_s": 0.0,
"gen_total_s": reduced_denoise_s,
"rife_s": generated41_rife_s,
"net_s": net_denoise_rife_s,
"speedup_vs_81": direct_denoise_s / net_denoise_rife_s,
},
{
"path": "generate_41_plus_rife81_decode_included",
"denoise_s": reduced_denoise_s,
"decode_s": float(reduced_cell.metrics["decode_s"]),
"gen_total_s": reduced_gen_total_s,
"rife_s": generated41_rife_s,
"net_s": net_gen_rife_s,
"speedup_vs_81": direct_gen_total_s / net_gen_rife_s,
},
],
"videos": {
"reference": _relative_to_output(reference_video, args.output_dir),
"drop41_rife81": _relative_to_output(reconstructed_video, args.output_dir),
"generated41": _relative_to_output(generated41_video, args.output_dir),
"generated41_rife81": _relative_to_output(generated41_rife_video, args.output_dir),
},
}
metrics_path = args.output_dir / "metrics.json"
metrics_path.write_text(json.dumps(result, indent=2))
report_path = _write_report(args, result)
print(json.dumps(result["speed_rows"], indent=2))
print(f"reconstruction_ms_ssim={reconstruction_ms_ssim:.6f}")
print(f"wrote {metrics_path}")
print(f"wrote {report_path}")
if __name__ == "__main__":
main()
+826
View File
@@ -0,0 +1,826 @@
# SPDX-License-Identifier: Apache-2.0
"""Prove-out benchmark for the MLX FastWan runtime (Apple Silicon).
Sweeps ``{dtype/quant} x {decoder}``, generates a clip per cell, and records the
latency breakdown, peak unified memory, and MS-SSIM (optionally LPIPS) against a
reference video. It emits a JSON blob and a markdown table -- the artifact that
turns "int8 + TAEHV looks good" into defensible numbers, and (via
``--assert-min-ssim``) a regression gate for the ``mx.compile`` work.
Design notes:
- Generation reuses the hybrid POC helpers in
``examples/inference/basic/mlx_wan_prompt_to_video.py`` (torch-MPS UMT5 encode
and Wan-VAE/TAEHV decode) plus the on-device MLX DMD sampler
(``fastvideo/mlx_runtime/sampling.py``); the denoise loop never leaves the
device.
- Quality reuses the tested MS-SSIM primitive
``fastvideo/tests/utils.py::compute_video_ssim_torchvision``.
- Reference: by default each cell is scored against the highest-fidelity cell
in the sweep (``fp16`` + ``wan-vae``), which needs no CUDA box and answers
"how much does int8/int4/TAEHV degrade vs the best local config". Pass
``--reference PATH`` to score against an external clip instead (e.g. the
torch-MPS or CUDA FastVideo output of the same model) for a "vs. the original
model" column.
Run on an Apple Silicon Mac (needs ``mlx`` + a torch build with MPS):
python fastvideo/benchmarks/mlx_fastwan_bench.py \
--modes fp16,bf16,int8,int4 --decoders taehv,wan-vae
"""
from __future__ import annotations
import argparse
import html
import json
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
from examples.inference.basic.mlx_wan_prompt_to_video import (
DEFAULT_MODEL_ROOT,
decode_latents_to_video,
encode_prompt,
make_rotary_embeddings,
)
from fastvideo.mlx_runtime.memory import add_memory_limit_args, apply_memory_limits, cleanup_mlx
# The highest-fidelity cell; used as the default SSIM reference when no external
# reference video is supplied.
REFERENCE_MODE = "fp16"
REFERENCE_DECODER = "wan-vae"
ALLOWED_MODES = ("fp16", "bf16", "int8", "int4", "mxfp8", "mxfp4", "nvfp4")
ALLOWED_DECODERS = ("taehv", "wan-vae")
@dataclass(frozen=True)
class PromptCase:
id: str
prompt: str
@dataclass(frozen=True)
class BenchmarkPreset:
height: int
width: int
num_frames: int
modes: str
decoders: str
mlx_memory_limit_gib: float | None = None
mlx_disable_cache: bool = False
torch_mps_high_watermark_ratio: float | None = None
torch_mps_low_watermark_ratio: float | None = None
PROMPT_SETS = {
"motion7": (
PromptCase("beach-sunset", "A slow cinematic sunset over ocean waves at a quiet beach."),
PromptCase("fox-forest", "A fox runs through a misty pine forest, leaves kicking up behind it."),
PromptCase("raccoon-sunflowers", "A raccoon walks through a sunflower field as petals move in the wind."),
PromptCase("surfing-cat", "A cat wearing sunglasses surfs across a bright blue ocean wave."),
PromptCase("burning-clock", "A vintage table clock burns on a wooden desk, flames flickering realistically."),
PromptCase("forest-walk", "Video game style footage of a man walking through a dense forest path."),
PromptCase("sea-dock-yachts", "Several yachts are parked at a sea dock while water ripples around them."),
),
}
BENCHMARK_PRESETS = {
"default":
BenchmarkPreset(
height=480,
width=832,
num_frames=81,
modes="fp16,bf16,int8,int4",
decoders="taehv,wan-vae",
),
"mac-16gb":
BenchmarkPreset(
height=448,
width=832,
num_frames=61,
modes="int8",
decoders="taehv",
mlx_memory_limit_gib=16.0,
mlx_disable_cache=True,
torch_mps_high_watermark_ratio=0.57,
torch_mps_low_watermark_ratio=0.0,
),
"mac-32gb":
BenchmarkPreset(
height=480,
width=832,
num_frames=81,
modes="int8,fp16",
decoders="taehv",
),
"mac-64gb":
BenchmarkPreset(
height=480,
width=832,
num_frames=81,
modes="int8,fp16",
decoders="taehv,wan-vae",
),
}
@dataclass
class Cell:
prompt_id: str
prompt: str
mode: str
decoder: str
video_path: Path
latents: np.ndarray
metrics: dict[str, float | int | str | bool | None] = field(default_factory=dict)
def _mode_to_dtype_quant(mode: str) -> tuple[str, str | None]:
"""Map a sweep mode to (MLX compute dtype, quantization spec).
Quantized modes keep fp16 activations and quantize only the DiT linear
weights (matching ``mlx_dit_from_diffusers_safetensors``).
"""
if mode == "bf16":
return "bf16", None
if mode == "fp16":
return "fp16", None
# int8/int4/mxfp*/nvfp4 -> fp16 activations + quantized weights.
return "fp16", mode
def _mx_dtype(mx, base: str):
return {"fp16": mx.float16, "bf16": mx.bfloat16, "fp32": mx.float32}[base]
def _parse_list(raw: str, allowed: tuple[str, ...], label: str) -> list[str]:
items = [x.strip() for x in raw.split(",") if x.strip()]
unknown = sorted(set(items) - set(allowed))
if unknown:
raise ValueError(f"Unsupported {label}: {unknown} (allowed: {list(allowed)})")
return items
def _safe_slug(value: str, *, fallback: str) -> str:
slug = "".join(ch.lower() if ch.isalnum() else "-" for ch in value.strip())
slug = "-".join(part for part in slug.split("-") if part)
return slug[:64] or fallback
def _load_prompt_cases(prompt: str, prompt_file: Path | None, prompt_set: str = "single") -> list[PromptCase]:
"""Load one prompt, a built-in prompt set, or a text/jsonl prompt file."""
if prompt_file is None:
if prompt_set == "single":
return [PromptCase(id="prompt-001", prompt=prompt)]
if prompt_set not in PROMPT_SETS:
raise ValueError(f"Unsupported prompt set: {prompt_set} (allowed: {sorted(PROMPT_SETS) + ['single']})")
return list(PROMPT_SETS[prompt_set])
cases: list[PromptCase] = []
for line_index, raw_line in enumerate(prompt_file.read_text().splitlines(), start=1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
prompt_id = f"prompt-{len(cases) + 1:03d}"
prompt_text = line
if prompt_file.suffix.lower() == ".jsonl":
item = json.loads(line)
prompt_text = str(item.get("prompt") or item.get("text") or item.get("caption") or "").strip()
if not prompt_text:
raise ValueError(f"{prompt_file}:{line_index} has no prompt/text/caption field")
prompt_id = str(item.get("id") or item.get("name") or prompt_id)
cases.append(PromptCase(id=_safe_slug(prompt_id, fallback=f"prompt-{len(cases) + 1:03d}"), prompt=prompt_text))
if not cases:
raise ValueError(f"No prompts found in {prompt_file}")
return cases
def denoise_dmd_on_device(
*,
mx,
dit,
latents,
encoder_hidden_states,
freqs_cis,
timesteps: list[int],
renoise_by_step: list[np.ndarray],
schedule,
dmd_step,
mx_dtype,
) -> tuple[np.ndarray, list[float]]:
"""Run the FastWan DMD loop entirely on the MLX device.
Mirrors the loop in ``mlx_wan_prompt_to_video.py`` (fp32 affine math, MLX RNG
re-noise) so the benchmark measures exactly the shipped path.
Returns the final latents plus per-step wall times. The first step carries
one-time costs (mx.compile tracing, kernel warm-up), so first-vs-steady
step timing is how the benchmark separates cold-start from steady-state
denoise throughput.
All host-side tensors (timesteps, re-noise draws) are uploaded before the
loop starts, so the per-step body performs no bulk host->device transfers
and step timings measure device work rather than staging copies.
"""
timesteps_mx = [mx.array([float(timestep)]).astype(mx.float32) for timestep in timesteps]
renoise_mx = [mx.array(renoise).astype(mx.float32) for renoise in renoise_by_step]
if timesteps_mx or renoise_mx:
mx.eval(*timesteps_mx, *renoise_mx)
step_times: list[float] = []
for step_index, timestep in enumerate(timesteps):
step_start = time.perf_counter()
noise_input_latent = latents
noise_pred = dit(latents.astype(mx_dtype), encoder_hidden_states, timesteps_mx[step_index], freqs_cis)
noise_input_f32 = noise_input_latent.astype(mx.float32)
pred_noise_f32 = noise_pred.astype(mx.float32)
if step_index < len(timesteps) - 1:
next_ts: float | None = float(timesteps[step_index + 1])
renoise = renoise_mx[step_index]
else:
next_ts, renoise = None, None
latents = dmd_step(
latents=noise_input_f32,
noise_input_latent=noise_input_f32,
pred_noise=pred_noise_f32,
schedule=schedule,
timestep=float(timestep),
next_timestep=next_ts,
noise=renoise,
).astype(mx_dtype)
mx.eval(latents)
step_times.append(time.perf_counter() - step_start)
return np.array(latents.astype(mx.float32)), step_times
def _peak_memory_bytes(mx) -> int:
try:
return int(mx.get_peak_memory())
except Exception: # noqa: BLE001 - best-effort telemetry only.
return 0
def _latent_delta_metrics(candidate: np.ndarray, baseline: np.ndarray) -> dict[str, float]:
diff = candidate.astype(np.float32) - baseline.astype(np.float32)
mse = float(np.mean(np.square(diff)))
signal = float(np.mean(np.square(baseline.astype(np.float32))))
return {
"latent_mse_vs_ref_mode": mse,
"latent_snr_db_vs_ref_mode": float(10.0 * np.log10(signal / mse)) if mse > 0 else float("inf"),
}
def _ms_ssim(reference_video: Path, candidate_video: Path, *, required: bool = False) -> float | None:
"""Mean MS-SSIM between two mp4s, via the repo's tested helper."""
if not reference_video.exists() or not candidate_video.exists():
return None
try:
from fastvideo.tests.utils import compute_video_ssim_torchvision
except ImportError as exc:
message = ("MS-SSIM is unavailable because `pytorch-msssim` is not installed. "
"Install FastVideo with the test extra, e.g. `uv pip install -e '.[mlx,test]'`, "
"or run without an SSIM assertion.")
if required:
raise RuntimeError(message) from exc
print(f"{message} Skipping MS-SSIM.")
return None
try:
ssim_values = compute_video_ssim_torchvision(str(reference_video), str(candidate_video), use_ms_ssim=True)
except ImportError as exc:
message = ("MS-SSIM is unavailable because `pytorch-msssim` is not installed. "
"Install FastVideo with the test extra, e.g. `uv pip install -e '.[mlx,test]'`, "
"or run without an SSIM assertion.")
if required:
raise RuntimeError(message) from exc
print(f"{message} Skipping MS-SSIM.")
return None
return float(ssim_values[0])
def _markdown_table(rows: list[dict]) -> str:
columns = [
("prompt_id", "prompt"),
("mode", "mode"),
("decoder", "decoder"),
("status", "status"),
("denoise_s", "denoise s"),
("decode_s", "decode s"),
("total_s", "total s"),
("peak_gib", "peak GiB"),
("ms_ssim_vs_ref", "MS-SSIM"),
("lpips_vs_ref", "LPIPS"),
]
header = "| " + " | ".join(label for _, label in columns) + " |"
sep = "| " + " | ".join("---" for _ in columns) + " |"
lines = [header, sep]
for row in rows:
cells = []
for key, _ in columns:
value = row.get(key)
if isinstance(value, float):
cells.append(f"{value:.3f}")
elif value is None:
cells.append("-")
else:
cells.append(str(value))
lines.append("| " + " | ".join(cells) + " |")
return "\n".join(lines)
def _format_metric(value) -> str:
if isinstance(value, float):
return f"{value:.3f}"
if value is None:
return "-"
return str(value)
def _html_grid(rows: list[dict]) -> str:
groups: dict[str, list[dict]] = {}
for row in rows:
groups.setdefault(str(row.get("prompt_id", "prompt")), []).append(row)
sections = []
for prompt_id, group_rows in groups.items():
prompt = next((str(row.get("prompt", "")) for row in group_rows if row.get("prompt")), "")
cards = []
for row in group_rows:
title = f"{row.get('mode', '-')} / {row.get('decoder', '-')}"
status = row.get("status", "-")
video_path = row.get("video_path")
if video_path:
media = f'<video src="{html.escape(str(video_path))}" muted loop controls playsinline></video>'
else:
media = f'<div class="missing">No video<br>{html.escape(str(row.get("error", "")))}</div>'
metrics = (f"status={status} · total={_format_metric(row.get('total_s'))}s · "
f"denoise={_format_metric(row.get('denoise_s'))}s · "
f"decode={_format_metric(row.get('decode_s'))}s · "
f"peak={_format_metric(row.get('peak_gib'))}GiB")
cards.append("<article>"
f"<h3>{html.escape(title)}</h3>"
f"{media}"
f"<p>{html.escape(metrics)}</p>"
"</article>")
sections.append("<section>"
f"<h2>{html.escape(prompt_id)}</h2>"
f"<p class=\"prompt\">{html.escape(prompt)}</p>"
f"<div class=\"grid\">{''.join(cards)}</div>"
"</section>")
return """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FastVideo MLX benchmark grid</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 24px; background: #111; color: #eee; }
button { margin-right: 8px; padding: 8px 12px; border-radius: 8px; border: 1px solid #555; background: #222; color: #eee; }
section { margin-top: 28px; }
.prompt { color: #bbb; max-width: 900px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }
article { background: #1b1b1b; border: 1px solid #333; border-radius: 12px; padding: 12px; }
h1, h2, h3 { margin: 0 0 10px; }
video { width: 100%; border-radius: 8px; background: #000; }
article p { color: #bbb; font-size: 13px; line-height: 1.4; }
.missing { min-height: 160px; display: grid; place-items: center; text-align: center; color: #f5b5b5; background: #2a1515; border-radius: 8px; padding: 12px; }
</style>
</head>
<body>
<h1>FastVideo MLX benchmark grid</h1>
<p>Use the controls below to start/stop every clip together for side-by-side inspection.</p>
<button onclick="for (const v of document.querySelectorAll('video')) { v.currentTime = 0; v.play(); }">Restart + play all</button>
<button onclick="for (const v of document.querySelectorAll('video')) v.pause();">Pause all</button>
""" + "\n".join(sections) + """
</body>
</html>
"""
def _write_html_grid(rows: list[dict], output_dir: Path) -> Path:
html_path = output_dir / "index.html"
html_path.write_text(_html_grid(rows))
return html_path
def _generate_cell(
*,
args,
mode: str,
decoder: str,
checkpoint_path: Path,
config_path: Path,
encoder_hidden_states,
freqs_cis,
timesteps: list[int],
latents_seed: np.ndarray,
renoise_by_step: list[np.ndarray],
) -> Cell:
import mlx.core as mx
from fastvideo.mlx_runtime.fastwan import mlx_dit_from_diffusers_safetensors
from fastvideo.mlx_runtime.sampling import MLXDMDSchedule, dmd_step
from fastvideo.models.schedulers.scheduling_flow_match_euler_discrete import FlowMatchEulerDiscreteScheduler
base_dtype, quantization = _mode_to_dtype_quant(mode)
mx_dtype = _mx_dtype(mx, base_dtype)
mx.clear_cache()
mx.reset_peak_memory()
load_start = time.perf_counter()
load_source = "diffusers"
if args.mlx_checkpoint_cache is not None:
from fastvideo.mlx_runtime.checkpoint import (
load_mlx_dit_checkpoint,
save_mlx_dit_checkpoint,
)
mode_ckpt_dir = args.mlx_checkpoint_cache / mode
if (mode_ckpt_dir / "mlx_dit.json").exists():
dit = load_mlx_dit_checkpoint(mode_ckpt_dir)
load_source = "mlx_checkpoint"
else:
dit = mlx_dit_from_diffusers_safetensors(
checkpoint_path,
config_path,
dtype=base_dtype,
quantization=quantization,
)
save_mlx_dit_checkpoint(dit, mode_ckpt_dir)
load_source = "diffusers_then_saved"
else:
dit = mlx_dit_from_diffusers_safetensors(
checkpoint_path,
config_path,
dtype=base_dtype,
quantization=quantization,
)
load_s = time.perf_counter() - load_start
load_peak = _peak_memory_bytes(mx)
scheduler = FlowMatchEulerDiscreteScheduler(shift=args.flow_shift)
schedule = MLXDMDSchedule.from_torch_scheduler(scheduler)
latents = mx.array(latents_seed).astype(mx_dtype)
mx.reset_peak_memory()
denoise_start = time.perf_counter()
latents_np, step_times = denoise_dmd_on_device(
mx=mx,
dit=dit,
latents=latents,
encoder_hidden_states=encoder_hidden_states.astype(mx_dtype),
freqs_cis=freqs_cis,
timesteps=timesteps,
renoise_by_step=renoise_by_step,
schedule=schedule,
dmd_step=dmd_step,
mx_dtype=mx_dtype,
)
denoise_s = time.perf_counter() - denoise_start
denoise_peak = _peak_memory_bytes(mx)
del dit, latents
cleanup_mlx(mx)
video_path = (args.output_dir / f"{args.current_prompt_id}" /
f"video_{mode}_{decoder}_{args.height}x{args.width}x{args.num_frames}.mp4")
decode_start = time.perf_counter()
decode_latents_to_video(
model_root=args.model_root,
latents_np=latents_np,
output_path=video_path,
fps=args.fps,
device_arg=args.torch_device,
dtype_arg=args.torch_dtype,
backend=decoder,
taehv_source_path=args.taehv_source_path,
taehv_checkpoint_path=args.taehv_checkpoint_path,
taehv_parallel=args.taehv_parallel,
)
decode_s = time.perf_counter() - decode_start
metrics: dict[str, float | int | str | bool | None] = {
"prompt_id": args.current_prompt_id,
"prompt": args.current_prompt,
"benchmark_preset": args.benchmark_preset,
"mode": mode,
"decoder": decoder,
"status": "ok",
"video_path": str(video_path.relative_to(args.output_dir)),
"load_s": load_s,
"load_source": load_source,
"denoise_s": denoise_s,
# The first step carries one-time costs (mx.compile tracing, kernel
# warm-up); steady-state throughput is the median of the rest.
"denoise_first_step_s": step_times[0] if step_times else None,
"denoise_steady_step_s": (float(np.median(step_times[1:])) if len(step_times) > 1 else None),
"decode_s": decode_s,
"total_s": load_s + denoise_s + decode_s,
"load_peak_gib": load_peak / (1024**3),
"peak_gib": max(load_peak, denoise_peak) / (1024**3),
"quantization": quantization or "none",
"compute_dtype": base_dtype,
"compile": os.environ.get("FASTVIDEO_MLX_COMPILE", "0") == "1",
"fast_norm": os.environ.get("FASTVIDEO_MLX_FAST_NORM", "0") == "1",
"mlx_memory_limit_gib": args.mlx_memory_limit_gib,
"mlx_cache_limit_gib": args.mlx_cache_limit_gib,
"mlx_disable_cache": args.mlx_disable_cache,
"mlx_wired_limit_gib": args.mlx_wired_limit_gib,
"torch_mps_high_watermark_ratio": args.torch_mps_high_watermark_ratio,
"torch_mps_low_watermark_ratio": args.torch_mps_low_watermark_ratio,
}
return Cell(
prompt_id=args.current_prompt_id,
prompt=args.current_prompt,
mode=mode,
decoder=decoder,
video_path=video_path,
latents=latents_np,
metrics=metrics,
)
def main() -> None:
preset_parser = argparse.ArgumentParser(add_help=False)
preset_parser.add_argument("--benchmark-preset", choices=tuple(BENCHMARK_PRESETS), default="default")
preset_args, _ = preset_parser.parse_known_args()
preset = BENCHMARK_PRESETS[preset_args.benchmark_preset]
parser = argparse.ArgumentParser(description="MLX FastWan prove-out benchmark (latency + quality).")
parser.add_argument("--benchmark-preset",
choices=tuple(BENCHMARK_PRESETS),
default=preset_args.benchmark_preset,
help="Memory-tier benchmark defaults. Explicit CLI flags override preset values.")
parser.add_argument("--model-root", type=Path, default=DEFAULT_MODEL_ROOT)
parser.add_argument("--prompt", default="A paper boat sails through a shallow stream in a mossy forest.")
parser.add_argument(
"--prompt-file",
type=Path,
default=None,
help=
"Optional prompt set. Plain text uses one prompt per non-empty line; .jsonl accepts prompt/text/caption plus optional id/name.",
)
parser.add_argument(
"--prompt-set",
choices=("single", *PROMPT_SETS.keys()),
default="single",
help="Built-in standard prompt set. Ignored when --prompt-file is supplied.",
)
parser.add_argument("--height", type=int, default=preset.height)
parser.add_argument("--width", type=int, default=preset.width)
parser.add_argument("--num-frames", type=int, default=preset.num_frames)
parser.add_argument("--dmd-denoising-steps", default="1000,757,522")
parser.add_argument("--flow-shift", type=float, default=8.0)
parser.add_argument("--max-sequence-length", type=int, default=512)
parser.add_argument("--seed", type=int, default=1024)
parser.add_argument("--fps", type=int, default=16)
parser.add_argument("--modes", default=preset.modes)
parser.add_argument("--decoders", default=preset.decoders)
parser.add_argument("--output-dir", type=Path, default=Path("video_samples/mlx_fastwan_bench"))
parser.add_argument("--torch-device", default="auto")
parser.add_argument("--torch-dtype", choices=("fp16", "fp32"), default="fp16")
parser.add_argument(
"--reference",
type=Path,
default=None,
help="External reference mp4 to score every cell against. Defaults to the fp16+wan-vae cell.",
)
parser.add_argument("--assert-min-ssim",
type=float,
default=None,
help="Fail if any cell's MS-SSIM vs the reference falls below this value.")
parser.add_argument("--compile",
action="store_true",
help="Enable mx.compile on the DiT forward (sets FASTVIDEO_MLX_COMPILE=1).")
parser.add_argument("--lpips", action="store_true", help="Also compute LPIPS (needs the `lpips` package).")
parser.add_argument("--taehv-source-path", type=Path, default=None)
parser.add_argument("--taehv-checkpoint-path", type=Path, default=None)
parser.add_argument("--taehv-parallel", action="store_true")
parser.add_argument(
"--mlx-checkpoint-cache",
type=Path,
default=None,
help="Directory of per-mode pre-quantized MLX checkpoints. The first cell of a mode "
"converts from Diffusers weights and saves here (load_source=diffusers_then_saved); "
"later cells and later runs reload without requantizing (load_source=mlx_checkpoint), "
"which is also how the checkpoint load-time win is measured.",
)
add_memory_limit_args(
parser,
mlx_memory_limit_gib=preset.mlx_memory_limit_gib,
mlx_disable_cache=preset.mlx_disable_cache,
torch_mps_high_watermark_ratio=preset.torch_mps_high_watermark_ratio,
torch_mps_low_watermark_ratio=preset.torch_mps_low_watermark_ratio,
)
args = parser.parse_args()
if args.compile:
os.environ["FASTVIDEO_MLX_COMPILE"] = "1"
import mlx.core as mx
runtime_limits = apply_memory_limits(
mlx_memory_limit_gib=args.mlx_memory_limit_gib,
mlx_cache_limit_gib=args.mlx_cache_limit_gib,
mlx_disable_cache=args.mlx_disable_cache,
mlx_wired_limit_gib=args.mlx_wired_limit_gib,
torch_mps_high_watermark_ratio=args.torch_mps_high_watermark_ratio,
torch_mps_low_watermark_ratio=args.torch_mps_low_watermark_ratio,
mx_module=mx,
).as_metrics()
import torch
mx.random.seed(args.seed)
torch.manual_seed(args.seed)
args.output_dir.mkdir(parents=True, exist_ok=True)
modes = _parse_list(args.modes, ALLOWED_MODES, "modes")
decoders = _parse_list(args.decoders, ALLOWED_DECODERS, "decoders")
config_path = args.model_root / "transformer/config.json"
checkpoint_path = args.model_root / "transformer/diffusion_pytorch_model.safetensors"
config = json.loads(config_path.read_text())
latent_frames = (args.num_frames - 1) // 4 + 1
latent_height = args.height // 8
latent_width = args.width // 8
freqs_cis = make_rotary_embeddings(
config,
latent_frames=latent_frames,
latent_height=latent_height,
latent_width=latent_width,
)
generator = torch.Generator(device="cpu").manual_seed(args.seed)
latents_seed = torch.randn(
(1, int(config["in_channels"]), latent_frames, latent_height, latent_width),
generator=generator,
dtype=torch.float32,
).numpy()
timesteps = [int(step.strip()) for step in args.dmd_denoising_steps.split(",") if step.strip()]
# Keep DMD stochasticity identical across benchmark cells. Without this,
# FP16/INT8/decoder comparisons can accidentally measure different re-noise
# samples instead of only quantization or decoder differences.
renoise_by_step = [
torch.randn(latents_seed.shape, generator=generator, dtype=torch.float32).numpy()
for _ in range(max(0,
len(timesteps) - 1))
]
from fastvideo.mlx_runtime.fastwan import UnsupportedMLXQuantizationError
prompt_cases = _load_prompt_cases(args.prompt, args.prompt_file, args.prompt_set)
cells: list[Cell] = []
unsupported_rows: list[dict] = []
for prompt_case in prompt_cases:
args.current_prompt_id = prompt_case.id
args.current_prompt = prompt_case.prompt
prompt_embeds = encode_prompt(
model_root=args.model_root,
prompt=prompt_case.prompt,
max_sequence_length=args.max_sequence_length,
device_arg=args.torch_device,
dtype_arg=args.torch_dtype,
)
encoder_hidden_states = mx.array(prompt_embeds.numpy())
for mode in modes:
for decoder in decoders:
print(f"=== cell: prompt={prompt_case.id} mode={mode} decoder={decoder} ===")
try:
cells.append(
_generate_cell(
args=args,
mode=mode,
decoder=decoder,
checkpoint_path=checkpoint_path,
config_path=config_path,
encoder_hidden_states=encoder_hidden_states,
freqs_cis=freqs_cis,
timesteps=timesteps,
latents_seed=latents_seed,
renoise_by_step=renoise_by_step,
))
except UnsupportedMLXQuantizationError as exc:
# Record the cell as unsupported and keep sweeping: a partial
# report on this MLX build beats crashing the whole run.
print(f"skipping cell (unsupported by this MLX build): {exc}")
unsupported_rows.append({
"prompt_id": prompt_case.id,
"prompt": prompt_case.prompt,
"mode": mode,
"decoder": decoder,
"status": "unsupported_by_mlx",
"error": str(exc),
})
if not cells:
metrics_path = args.output_dir / "metrics.json"
metrics_path.write_text(json.dumps(unsupported_rows, indent=2))
raise SystemExit(f"No benchmark cell could run: every requested mode is unsupported by this MLX build. "
f"Wrote {metrics_path}.")
# Resolve one internal reference per prompt. A single external reference, if
# supplied, is used for every prompt and only video metrics are computed.
reference_by_prompt: dict[str, tuple[Path, np.ndarray | None]] = {}
if args.reference is not None:
for prompt_case in prompt_cases:
reference_by_prompt[prompt_case.id] = (args.reference, None)
else:
for prompt_case in prompt_cases:
prompt_cells = [c for c in cells if c.prompt_id == prompt_case.id]
if not prompt_cells:
continue
ref_cell = next(
(c for c in prompt_cells if c.mode == REFERENCE_MODE and c.decoder == REFERENCE_DECODER),
prompt_cells[0],
)
reference_by_prompt[prompt_case.id] = (ref_cell.video_path, ref_cell.latents)
print(f"Using internal reference cell for {prompt_case.id}: "
f"mode={ref_cell.mode} decoder={ref_cell.decoder}")
lpips_fn = _load_lpips() if args.lpips else None
rows: list[dict] = []
failures: list[str] = []
for cell in cells:
reference_video, reference_latents = reference_by_prompt[cell.prompt_id]
ms_ssim = _ms_ssim(Path(reference_video), cell.video_path, required=args.assert_min_ssim is not None)
cell.metrics["ms_ssim_vs_ref"] = ms_ssim
cell.metrics.update(runtime_limits)
if reference_latents is not None:
cell.metrics.update(_latent_delta_metrics(cell.latents, reference_latents))
cell.metrics["lpips_vs_ref"] = (_lpips_between(lpips_fn, Path(reference_video), cell.video_path)
if lpips_fn else None)
if args.assert_min_ssim is not None and ms_ssim is not None and ms_ssim < args.assert_min_ssim:
failures.append(
f"{cell.prompt_id}/{cell.mode}/{cell.decoder}: MS-SSIM {ms_ssim:.4f} < {args.assert_min_ssim}")
rows.append(dict(cell.metrics))
print(json.dumps(cell.metrics, indent=2))
rows.extend(unsupported_rows)
metrics_path = args.output_dir / "metrics.json"
metrics_path.write_text(json.dumps(rows, indent=2))
table_path = args.output_dir / "metrics.md"
table = _markdown_table(rows)
table_path.write_text(table + "\n")
html_path = _write_html_grid(rows, args.output_dir)
print("\n" + table)
print(f"\nWrote {metrics_path}, {table_path}, and {html_path}")
if failures:
raise SystemExit("SSIM regression gate failed:\n " + "\n ".join(failures))
def _load_lpips() -> object | None:
"""Return an LPIPS model, or ``None`` if the optional dep is unavailable."""
try:
import lpips # noqa: PLC0415 - optional dependency.
except ImportError:
print("LPIPS requested but the `lpips` package is not installed; skipping (install `.[eval]`).")
return None
return lpips.LPIPS(net="alex")
def _lpips_between(lpips_fn, reference_video: Path, candidate_video: Path) -> float | None:
if lpips_fn is None or not reference_video.exists() or not candidate_video.exists():
return None
import torch
ref = _read_video_frames(reference_video)
cand = _read_video_frames(candidate_video)
if ref is None or cand is None or ref.shape != cand.shape:
return None
# LPIPS expects NCHW in [-1, 1].
ref_t = torch.from_numpy(ref).permute(0, 3, 1, 2).float() / 127.5 - 1.0
cand_t = torch.from_numpy(cand).permute(0, 3, 1, 2).float() / 127.5 - 1.0
with torch.no_grad():
scores = lpips_fn(ref_t, cand_t)
return float(scores.mean().item())
def _read_video_frames(path: Path) -> np.ndarray | None:
try:
import cv2
except ImportError:
return None
cap = cv2.VideoCapture(str(path))
frames = []
try:
while True:
ok, frame_bgr = cap.read()
if not ok:
break
frames.append(cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB))
finally:
cap.release()
if not frames:
return None
return np.stack(frames, axis=0)
if __name__ == "__main__":
main()
+43 -18
View File
@@ -808,14 +808,19 @@ class VideoGenerator:
latent_batch_size = _infer_latent_batch_size(batch)
is_latent_output = fastvideo_args.output_type == "latent"
needs_frame_output = batch.return_frames or (batch.save_video and not is_latent_output)
needs_samples_buffer = batch.return_frames or needs_frame_output
# When ``output_type == "latent"`` the forward output has latent
# shape (e.g. ``[B, C_latent, T_latent, H_latent, W_latent]``)
# rather than the pre-allocation's pixel shape. Skip the pinned
# ~50 MB buffer entirely. Also skip it for metadata-only calls;
# neither the result nor save path will consume the decoded tensor.
# A populated ``samples`` has exactly one consumer — the result
# dict (``"samples": samples if batch.return_frames else None``).
# Post-decode frame building reads ``output_batch.output``
# directly (the GPU ``vid_u8`` path), not ``samples``. So when
# ``return_frames=False`` the pinned fp32 alloc + D->H copy are
# dead weight — the CLI generate flow (``save_video=True``,
# ``return_frames=False``) hits this on every call.
# ``output_type == "latent"`` keeps its existing branch (shape
# mismatch falls through to ``.cpu()`` below) for callers that
# *do* ask for the latent samples via ``return_frames=True``.
# ``skip_pixel_prealloc`` also gates the slow-path warning.
skip_pixel_prealloc = is_latent_output or not needs_samples_buffer
needs_samples_out = batch.return_frames
skip_pixel_prealloc = is_latent_output or not needs_samples_out
if skip_pixel_prealloc:
samples = torch.empty(0, device='cpu')
else:
@@ -835,9 +840,11 @@ class VideoGenerator:
"This usually means the executor/pipeline failed earlier.")
audio_only = bool(output_batch.extra.get("audio_only"))
if not needs_samples_buffer or (audio_only and not batch.return_frames):
# Metadata-only/audio-only request: keep the empty placeholder and
# avoid the decoded tensor D->H copy.
if not needs_samples_out:
# Nothing downstream reads ``samples`` (the result dict
# returns None when ``return_frames=False``); keep the empty
# placeholder allocated above and skip the fp32 D->H copy
# entirely.
pass
elif audio_only:
# Audio-only return-frames requests expose the small placeholder
@@ -869,8 +876,13 @@ class VideoGenerator:
# `GenerationResult.size` describes the produced media, not only the
# base-stage request. Refiner pipelines can change the final pixel
# dimensions, so derive this result metadata from the decoded output.
# Read the geometry from `output_batch.output` (a shape-only access,
# no D->H copy): when `return_frames=False` the `samples` mirror
# stays an empty placeholder and no longer carries the decoded
# shape. Metadata-only calls keep the request fallback and never
# inspect the (possibly dropped) worker output.
output_size = _resolve_output_size(
samples,
output_batch.output if needs_frame_output else samples,
(target_height, target_width, batch.num_frames),
pixel_output=not is_latent_output and not audio_only,
)
@@ -882,13 +894,26 @@ class VideoGenerator:
elif not needs_frame_output:
frames = None
else:
videos = rearrange(samples, "b c t h w -> t b c h w")
frames = []
for x in videos:
x = torchvision.utils.make_grid(x, nrow=6)
x = x.permute(1, 2, 0).squeeze(-1)
x = (x * 255).to(torch.uint8)
frames.append(x.contiguous().cpu().numpy())
# Quantize on the source device (typically CUDA) BEFORE the
# device->host copy. `samples` above is just the pinned-CPU
# mirror of `output_batch.output` (`samples.copy_(output)` or
# `output.cpu()`) with no intervening preprocessing, so reading
# `output_batch.output` here is the same data. The old path
# paid a full fp32 video D->H copy (which scales with
# resolution x frames x batch) and then a single-threaded
# per-frame CPU *255/cast loop. Casting to uint8 on-device
# makes the transfer 4x smaller, ships it in a single copy,
# and moves the elementwise work onto the GPU. clamp_() also
# fixes a latent overflow bug: VAE output slightly outside
# [0, 1] wrapped mod 256 in the old unclamped cast.
# (Equivalence is SSIM-gated, not bit-exact: float->uint8
# differs <=1 LSB CPU vs GPU.)
src = output_batch.output
vid_u8 = (src * 255).clamp_(0, 255).to(torch.uint8)
vid_u8 = rearrange(vid_u8, "b c t h w -> t b c h w").cpu()
frames = [
torchvision.utils.make_grid(x, nrow=6).permute(1, 2, 0).squeeze(-1).contiguous().numpy() for x in vid_u8
]
postprocess_time = time.perf_counter() - postprocess_start
logger.info("PostDecodeFrameProcessStage completed in %.3f s", postprocess_time)
if logging_info is not None:
+115
View File
@@ -0,0 +1,115 @@
# SPDX-License-Identifier: Apache-2.0
"""Experimental Apple MLX runtime helpers.
This package is intentionally small for now. It exists to grow the Apple-native
FastWan path in measurable steps: shape planning, primitive benchmarks, then
Wan block parity, then full DiT/runtime support.
"""
from fastvideo.mlx_runtime.fastwan import (
FastWanShape,
MLXQuantizationSpec,
MLXWanDiT,
MLXWanTransformerBlock,
UnsupportedMLXQuantizationError,
ensure_quantization_supported,
fastwan_shape,
fastwan_shape_from_config,
mlx_dit_from_diffusers_safetensors,
mlx_block_weights_from_torch,
mlx_block_weights_from_diffusers_safetensors,
quantization_support_error,
)
from fastvideo.mlx_runtime.checkpoint import (
load_mlx_dit_checkpoint,
save_mlx_dit_checkpoint,
)
from fastvideo.mlx_runtime.memory import (
AppliedMemoryLimits,
add_memory_limit_args,
apply_memory_limits,
gib_to_bytes,
)
from fastvideo.mlx_runtime.refine import (
DEFAULT_REFINE_SIGMA,
RefinePlan,
TwoPassResult,
default_refine_timesteps,
plan_refine_resolutions,
prepare_refine_latents,
refine_sigma_from_schedule,
run_dmd_loop,
run_two_pass_dmd,
upsample_latents_spatial,
)
from fastvideo.mlx_runtime.frame_upsample import (
DEFAULT_PIXEL_UPSAMPLE_MODE,
PIXEL_UPSAMPLE_MODES,
unsharp,
upsample_frame,
upsample_frames,
)
from fastvideo.mlx_runtime.fast_spatial import (
DEFAULT_FAST_SPATIAL_SHARPEN,
FastSpatialPlan,
apply_fast_spatial_upsample,
plan_fast_spatial,
resolve_spatial_mode,
)
from fastvideo.mlx_runtime.prompt_enhance import (
DEFAULT_ENHANCE_SYSTEM_PROMPT,
DEFAULT_MLX_LM_MODEL,
EnhanceResult,
enhance_prompt,
enhance_prompt_template,
enhance_result_as_metrics,
load_or_enhance_prompt,
)
__all__ = [
"AppliedMemoryLimits",
"DEFAULT_ENHANCE_SYSTEM_PROMPT",
"DEFAULT_MLX_LM_MODEL",
"DEFAULT_FAST_SPATIAL_SHARPEN",
"DEFAULT_PIXEL_UPSAMPLE_MODE",
"DEFAULT_REFINE_SIGMA",
"EnhanceResult",
"FastSpatialPlan",
"FastWanShape",
"MLXQuantizationSpec",
"MLXWanDiT",
"MLXWanTransformerBlock",
"RefinePlan",
"TwoPassResult",
"UnsupportedMLXQuantizationError",
"add_memory_limit_args",
"apply_fast_spatial_upsample",
"apply_memory_limits",
"enhance_prompt",
"enhance_prompt_template",
"enhance_result_as_metrics",
"ensure_quantization_supported",
"fastwan_shape",
"fastwan_shape_from_config",
"gib_to_bytes",
"load_mlx_dit_checkpoint",
"load_or_enhance_prompt",
"mlx_dit_from_diffusers_safetensors",
"mlx_block_weights_from_diffusers_safetensors",
"mlx_block_weights_from_torch",
"PIXEL_UPSAMPLE_MODES",
"default_refine_timesteps",
"plan_fast_spatial",
"plan_refine_resolutions",
"prepare_refine_latents",
"quantization_support_error",
"refine_sigma_from_schedule",
"resolve_spatial_mode",
"run_dmd_loop",
"run_two_pass_dmd",
"save_mlx_dit_checkpoint",
"unsharp",
"upsample_frame",
"upsample_frames",
"upsample_latents_spatial",
]
+273
View File
@@ -0,0 +1,273 @@
# SPDX-License-Identifier: Apache-2.0
"""Pre-quantized MLX checkpoint save/load for the FastWan DiT.
Loading the Diffusers fp32/fp16 checkpoint and quantizing at startup costs
both download size and load time on every run. This module persists an already
cast (and optionally already quantized) ``MLXWanDiT`` so 16 GB users download
and load roughly half the bytes and skip requantization entirely:
dit = mlx_dit_from_diffusers_safetensors(ckpt, cfg, quantization="int8")
save_mlx_dit_checkpoint(dit, "FastWan2.1-T2V-1.3B-mlx-int8")
...
dit = load_mlx_dit_checkpoint("FastWan2.1-T2V-1.3B-mlx-int8")
Format (one directory):
- ``mlx_dit.safetensors`` — every array, saved with ``mx.save_safetensors``.
Plain weights keep their key; a quantized weight ``K`` is stored as the
packed ``K`` plus ``K.scales`` (and ``K.biases`` for affine modes).
- ``mlx_dit.json`` — format version, the model config, the quantization spec,
and which keys are quantized, so the loader can rebuild ``QuantizedMatrix``
objects without guessing.
"""
from __future__ import annotations
import json
import shutil
import tempfile
from pathlib import Path
from typing import Any
from fastvideo.logger import init_logger
from fastvideo.mlx_runtime.fastwan import (
MLXQuantizationSpec,
MLXWanDiT,
MLXWanTransformerBlock,
QuantizedMatrix,
ensure_quantization_supported,
)
logger = init_logger(__name__)
FORMAT_VERSION = 1
WEIGHTS_FILENAME = "mlx_dit.safetensors"
MANIFEST_FILENAME = "mlx_dit.json"
_BLOCK_PREFIX = "blocks"
_DTYPE_TO_NAME = {"float16": "fp16", "bfloat16": "bf16", "float32": "fp32"}
def _dtype_name(dtype) -> str:
"""Return the manifest name for a supported MLX data type.
Parameters:
dtype: The MLX data type to convert.
Returns:
str: The manifest name corresponding to the data type.
Raises:
ValueError: If the data type is not supported for checkpointing.
"""
import mlx.core as mx
for raw, name in _DTYPE_TO_NAME.items():
if dtype == getattr(mx, raw):
return name
raise ValueError(f"Unsupported MLX dtype for checkpointing: {dtype}")
def _name_to_dtype(name: str):
"""Convert a manifest dtype name to its corresponding MLX dtype.
Parameters:
name (str): Manifest name, such as ``"fp16"``, ``"bf16"``, or ``"fp32"``.
Returns:
The corresponding MLX dtype.
"""
import mlx.core as mx
return {"fp16": mx.float16, "bf16": mx.bfloat16, "fp32": mx.float32}[name]
def _flatten_weights(dit: MLXWanDiT) -> dict[str, Any]:
"""Combine model and transformer-block weights into a single flattened mapping.
Parameters:
dit (MLXWanDiT): Model whose weights should be flattened.
Returns:
dict[str, Any]: Mapping containing top-level weights and indexed transformer-block weights.
"""
flat: dict[str, Any] = dict(dit.weights)
for index, block in enumerate(dit.blocks):
for name, value in block.weights.items():
flat[f"{_BLOCK_PREFIX}.{index}.{name}"] = value
return flat
def save_mlx_dit_checkpoint(dit: MLXWanDiT, checkpoint_dir: str | Path) -> Path:
"""Save a plain or quantized MLX Wan DiT checkpoint to a directory.
Parameters:
dit (MLXWanDiT): Model whose weights and configuration will be saved.
checkpoint_dir (str | Path): Destination directory for the checkpoint.
Returns:
Path: Path to the checkpoint directory.
"""
import mlx.core as mx
checkpoint_dir = Path(checkpoint_dir)
arrays: dict[str, Any] = {}
quantized: dict[str, dict[str, Any]] = {}
spec: MLXQuantizationSpec | None = None
for key, value in _flatten_weights(dit).items():
if isinstance(value, QuantizedMatrix):
if spec is not None and value.spec != spec:
raise ValueError(f"Mixed quantization specs in one checkpoint ({spec} vs {value.spec} at '{key}') "
"are not supported.")
spec = value.spec
arrays[key] = value.weight
arrays[f"{key}.scales"] = value.scales
if value.biases is not None:
arrays[f"{key}.biases"] = value.biases
quantized[key] = {
"dequantized_dtype": _dtype_name(value.dequantized_dtype),
"has_biases": value.biases is not None,
}
else:
arrays[key] = value
manifest = {
"format_version": FORMAT_VERSION,
"config": dit.config,
"num_blocks": len(dit.blocks),
"quantization": None if spec is None else {
"mode": spec.mode,
"bits": spec.bits,
"group_size": spec.group_size,
},
"quantized_keys": quantized,
}
manifest_json = json.dumps(manifest, indent=2)
checkpoint_dir.parent.mkdir(parents=True, exist_ok=True)
staging_dir = Path(tempfile.mkdtemp(dir=checkpoint_dir.parent, prefix=f".{checkpoint_dir.name}.staging-"))
backup_root: Path | None = None
try:
staged_weights = staging_dir / WEIGHTS_FILENAME
staged_manifest = staging_dir / MANIFEST_FILENAME
mx.save_safetensors(str(staged_weights), arrays)
staged_manifest.write_text(manifest_json)
if checkpoint_dir.exists():
backup_root = Path(tempfile.mkdtemp(dir=checkpoint_dir.parent, prefix=f".{checkpoint_dir.name}.backup-"))
try:
checkpoint_dir.replace(backup_root / checkpoint_dir.name)
except Exception:
shutil.rmtree(backup_root, ignore_errors=True)
raise
try:
staging_dir.replace(checkpoint_dir)
except Exception:
if backup_root is not None:
(backup_root / checkpoint_dir.name).replace(checkpoint_dir)
shutil.rmtree(backup_root, ignore_errors=True)
raise
if backup_root is not None:
shutil.rmtree(backup_root, ignore_errors=True)
finally:
shutil.rmtree(staging_dir, ignore_errors=True)
logger.info("Saved MLX DiT checkpoint (%d arrays, quantization=%s) to %s", len(arrays),
spec.label if spec else "none", checkpoint_dir)
return checkpoint_dir
def load_mlx_dit_checkpoint(checkpoint_dir: str | Path, *, compile: bool = False) -> MLXWanDiT:
"""
Reconstruct an MLXWanDiT model from a versioned checkpoint.
Parameters:
checkpoint_dir (str | Path): Directory containing the checkpoint manifest and weights.
compile (bool): Whether to configure the reconstructed model for compilation.
Returns:
MLXWanDiT: The reconstructed model.
Raises:
FileNotFoundError: If the checkpoint manifest or weights file is missing.
ValueError: If the checkpoint format is unsupported or block weights are incomplete.
"""
import mlx.core as mx
checkpoint_dir = Path(checkpoint_dir)
manifest_path = checkpoint_dir / MANIFEST_FILENAME
weights_path = checkpoint_dir / WEIGHTS_FILENAME
if not manifest_path.exists() or not weights_path.exists():
raise FileNotFoundError(f"Not an MLX DiT checkpoint directory: {checkpoint_dir} "
f"(expected {MANIFEST_FILENAME} and {WEIGHTS_FILENAME}).")
manifest = json.loads(manifest_path.read_text())
version = manifest.get("format_version")
if version != FORMAT_VERSION:
raise ValueError(f"MLX DiT checkpoint {checkpoint_dir} has format_version={version}; "
f"this FastVideo build reads version {FORMAT_VERSION}. Re-export the checkpoint.")
spec = None
if manifest["quantization"] is not None:
spec = MLXQuantizationSpec(**manifest["quantization"])
# The packed layout of mx.quantize output is mode-specific, so a build
# that cannot run the mode cannot use these arrays at all.
ensure_quantization_supported(spec)
arrays = mx.load(str(weights_path))
quantized_keys: dict[str, dict[str, Any]] = manifest["quantized_keys"]
def rebuild(key: str):
"""
Reconstructs a weight array or quantized matrix from checkpoint data.
Parameters:
key (str): The weight key to rebuild.
Returns:
The stored array for an unquantized weight or a reconstructed quantized matrix.
"""
if key not in quantized_keys:
return arrays[key]
info = quantized_keys[key]
assert spec is not None, f"Quantized key '{key}' in a checkpoint without a quantization spec"
return QuantizedMatrix(
weight=arrays[key],
scales=arrays[f"{key}.scales"],
biases=arrays[f"{key}.biases"] if info["has_biases"] else None,
spec=spec,
dequantized_dtype=_name_to_dtype(info["dequantized_dtype"]),
)
config = manifest["config"]
block_keys: dict[int, list[str]] = {}
top_level_keys: list[str] = []
for key in arrays:
if key.endswith(".scales") or key.endswith(".biases"):
continue
if key.startswith(f"{_BLOCK_PREFIX}."):
index_str, _, _ = key[len(_BLOCK_PREFIX) + 1:].partition(".")
block_keys.setdefault(int(index_str), []).append(key)
else:
top_level_keys.append(key)
weights = {key: rebuild(key) for key in top_level_keys}
num_blocks = int(manifest["num_blocks"])
if sorted(block_keys) != list(range(num_blocks)):
raise ValueError(f"MLX DiT checkpoint {checkpoint_dir} is missing block weights: "
f"manifest says {num_blocks} blocks, found indices {sorted(block_keys)}.")
inner_dim = int(config["num_attention_heads"]) * int(config["attention_head_dim"])
blocks = []
for index in range(num_blocks):
prefix = f"{_BLOCK_PREFIX}.{index}."
block_weights = {key[len(prefix):]: rebuild(key) for key in block_keys[index]}
blocks.append(
MLXWanTransformerBlock(
block_weights,
dim=inner_dim,
ffn_dim=int(config["ffn_dim"]),
num_heads=int(config["num_attention_heads"]),
eps=float(config["eps"]),
))
return MLXWanDiT(weights, blocks, config, compile=compile)
+228
View File
@@ -0,0 +1,228 @@
# SPDX-License-Identifier: Apache-2.0
"""Spatial fast mode for the MLX Wan runtime (RIFE's spatial twin).
RIFE ``--fast`` cuts *frames* (temporal). This module cuts *pixels*
(spatial): denoise at ``target // scale``, decode at that size, then
resample the decoded frames up to the target. No second denoise pass —
that is ``--refine`` (quality). The two compose:
* ``--fast-spatial`` alone → speed (≈ scale² fewer tokens)
* ``--refine`` alone → quality two-pass (H3 / LTX-2)
* ``--fast`` + ``--refine`` → fewer frames at base res, full-res refine
* ``--fast`` + ``--fast-spatial`` → fewer frames *and* fewer pixels
The upsample runs in **pixel** space, after the VAE decode. It used to run
in latent space (bilinear over the latent H/W plane, sharing the refine
hand-off primitive) and that is what made spatial fast mode incoherent: an
interpolated Wan latent is off the decoder's manifold, so decode returned
the right silhouette under a smeared veil. ``--refine`` can get away with
the latent-space upsample because a second DMD pass re-denoises the result;
spatial fast mode hands the latent straight to the decoder, so it cannot.
See :mod:`fastvideo.mlx_runtime.frame_upsample` for the full rationale.
MetalFX is intentionally not used: it needs game-engine motion vectors
and depth that diffusion output lacks.
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
import numpy as np
from fastvideo.logger import init_logger
from fastvideo.mlx_runtime.frame_upsample import (
DEFAULT_PIXEL_UPSAMPLE_MODE,
PIXEL_UPSAMPLE_MODES,
upsample_frames,
)
from fastvideo.mlx_runtime.refine import RefinePlan, plan_refine_resolutions
logger = init_logger(__name__)
# Resampling from a smaller decode loses high-frequency detail the same way
# RIFE's flow warp does, so spatial fast mode borrows ``--fast``'s remedy: a
# light unsharp pass. 0.4 recovers perceived crispness on Wan2.1 output at 2x
# without the halos that show up by ~0.8.
DEFAULT_FAST_SPATIAL_SHARPEN = 0.4
@dataclass(frozen=True)
class FastSpatialPlan:
"""Resolved geometry for a spatial-fast (upsample-only) run."""
plan: RefinePlan
upsample_mode: str
sharpen: float = DEFAULT_FAST_SPATIAL_SHARPEN
@property
def enabled(self) -> bool:
"""
Determine whether spatial scaling is enabled.
Returns:
`true` if the spatial scale is greater than one, `false` otherwise.
"""
return self.plan.spatial_scale > 1
@property
def scale(self) -> int:
"""Provides the configured spatial scaling factor.
Returns:
int: The spatial scaling factor.
"""
return self.plan.spatial_scale
@property
def target_height(self) -> int:
"""
Return the target output height for the spatial plan.
Returns:
int: Target output height in pixels.
"""
return self.plan.target_height
@property
def target_width(self) -> int:
"""Return the target image width in pixels.
Returns:
int: The target image width.
"""
return self.plan.target_width
@property
def stage1_height(self) -> int:
"""
Provide the stage-one latent height used for reduced-resolution processing.
Returns:
int: The stage-one latent height.
"""
return self.plan.stage1_height
@property
def stage1_width(self) -> int:
"""Get the stage-one latent width.
Returns:
int: The stage-one latent width.
"""
return self.plan.stage1_width
def plan_fast_spatial(
*,
height: int,
width: int,
num_frames: int,
spatial_scale: int = 2,
vae_spatial_compression: int = 8,
vae_temporal_compression: int = 4,
patch_size: tuple[int, int, int] = (1, 2, 2),
upsample_mode: str = DEFAULT_PIXEL_UPSAMPLE_MODE,
sharpen: float = DEFAULT_FAST_SPATIAL_SHARPEN,
enabled: bool = True,
) -> FastSpatialPlan:
"""
Build a plan for reduced-resolution denoising followed by pixel-space upsampling.
Parameters:
upsample_mode (str): Pixel interpolation kernel, one of
:data:`~fastvideo.mlx_runtime.frame_upsample.PIXEL_UPSAMPLE_MODES`.
sharpen (float): Unsharp strength applied after the resize.
Returns:
FastSpatialPlan: The validated spatial-fast processing plan.
Raises:
ValueError: If the upsample mode is unsupported or ``sharpen`` is negative.
"""
if upsample_mode not in PIXEL_UPSAMPLE_MODES:
raise ValueError(f"Unsupported upsample mode: {upsample_mode!r} "
f"(expected one of {', '.join(PIXEL_UPSAMPLE_MODES)})")
if sharpen < 0.0:
raise ValueError(f"sharpen must be >= 0, got {sharpen}")
plan = plan_refine_resolutions(
height=height,
width=width,
num_frames=num_frames,
spatial_scale=spatial_scale,
vae_spatial_compression=vae_spatial_compression,
vae_temporal_compression=vae_temporal_compression,
patch_size=patch_size,
enabled=enabled,
mode_label="fast-spatial",
)
if plan.spatial_scale > 1:
logger.info(
"[MLX fast-spatial] denoise+decode %dx%d → upsample %dx to %dx%d (%s, sharpen=%.2f)",
plan.stage1_width,
plan.stage1_height,
plan.spatial_scale,
plan.target_width,
plan.target_height,
upsample_mode,
sharpen,
)
return FastSpatialPlan(plan=plan, upsample_mode=upsample_mode, sharpen=sharpen)
def apply_fast_spatial_upsample(
frames: Iterable[np.ndarray],
spatial: FastSpatialPlan,
) -> list[np.ndarray]:
"""Resample decoded stage-1 frames up to the target resolution.
This runs on decoded RGB frames, *not* on latents: see the module
docstring for why the latent-space version produced a blurred veil.
Parameters:
frames (Iterable[np.ndarray]): Decoded HxWx3 uint8 RGB frames, produced
by decoding at the stage-one resolution.
spatial (FastSpatialPlan): Plan defining the target size, interpolation
kernel, and unsharp strength.
Returns:
list[np.ndarray]: Frames at the target resolution. When spatial scaling
is disabled the frames are returned unchanged, as a list.
"""
if not spatial.enabled:
return list(frames)
return upsample_frames(
frames,
width=spatial.target_width,
height=spatial.target_height,
mode=spatial.upsample_mode,
sharpen=spatial.sharpen,
)
def resolve_spatial_mode(
*,
refine: bool,
fast_spatial: bool,
) -> str:
"""Select the active spatial processing mode, with refinement taking precedence.
Returns:
str: ``"refine"`` when refinement is enabled, ``"fast_spatial"`` when
spatial-fast processing is enabled, or ``"off"`` otherwise.
"""
if refine:
return "refine"
if fast_spatial:
return "fast_spatial"
return "off"
__all__ = [
"DEFAULT_FAST_SPATIAL_SHARPEN",
"FastSpatialPlan",
"apply_fast_spatial_upsample",
"plan_fast_spatial",
"resolve_spatial_mode",
]
+980
View File
@@ -0,0 +1,980 @@
# SPDX-License-Identifier: Apache-2.0
# mypy: disable-error-code=no-untyped-call
"""FastWan-oriented helpers for the experimental MLX runtime path."""
from __future__ import annotations
import json
import math
import os
import statistics
import time
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from collections.abc import Callable
from fastvideo.logger import init_logger
if TYPE_CHECKING:
import mlx.core as mx
import torch
logger = init_logger(__name__)
@dataclass(frozen=True)
class FastWanShape:
height: int
width: int
num_frames: int
latent_frames: int
latent_height: int
latent_width: int
patch_frames: int
patch_height: int
patch_width: int
tokens: int
hidden_size: int
num_heads: int
head_dim: int
class UnsupportedMLXQuantizationError(ValueError):
"""A quantization mode the installed MLX build cannot execute.
Raised by :func:`ensure_quantization_supported` before any model weights
are loaded, so callers (CLI flags, benchmark sweeps) can fail fast with an
actionable message -- or skip the mode -- instead of crashing deep inside
``mx.quantize`` mid-load.
"""
@dataclass(frozen=True)
class MLXQuantizationSpec:
"""MLX quantized-matmul configuration for DiT linear weights."""
mode: str
bits: int | None = None
group_size: int | None = None
@classmethod
def from_name(cls, name: str | None) -> MLXQuantizationSpec | None:
if name is None or name in {"", "none", "fp16", "fp32"}:
return None
if name == "int8":
return cls(mode="affine", bits=8, group_size=64)
if name == "int4":
return cls(mode="affine", bits=4, group_size=64)
if name == "mxfp8":
return cls(mode="mxfp8")
if name == "mxfp4":
return cls(mode="mxfp4")
if name == "nvfp4":
return cls(mode="nvfp4")
raise ValueError(f"Unsupported MLX quantization mode: {name}")
@property
def label(self) -> str:
if self.mode == "affine":
return f"int{self.bits}"
return self.mode
@dataclass(frozen=True)
class QuantizedMatrix:
weight: mx.array
scales: mx.array
biases: mx.array | None
spec: MLXQuantizationSpec
dequantized_dtype: mx.Dtype
def fastwan_shape(
*,
height: int,
width: int,
num_frames: int,
vae_temporal_compression: int = 4,
vae_spatial_compression: int = 8,
patch_size: tuple[int, int, int] = (1, 2, 2),
num_heads: int = 12,
head_dim: int = 128,
) -> FastWanShape:
"""Return the approximate DiT token shape for Wan/FastWan T2V inference."""
latent_frames = (num_frames - 1) // vae_temporal_compression + 1
latent_height = height // vae_spatial_compression
latent_width = width // vae_spatial_compression
patch_frames = latent_frames // patch_size[0]
patch_height = latent_height // patch_size[1]
patch_width = latent_width // patch_size[2]
tokens = patch_frames * patch_height * patch_width
return FastWanShape(
height=height,
width=width,
num_frames=num_frames,
latent_frames=latent_frames,
latent_height=latent_height,
latent_width=latent_width,
patch_frames=patch_frames,
patch_height=patch_height,
patch_width=patch_width,
tokens=tokens,
hidden_size=num_heads * head_dim,
num_heads=num_heads,
head_dim=head_dim,
)
def fastwan_shape_from_config(
config_path: str | Path,
*,
height: int,
width: int,
num_frames: int,
) -> FastWanShape:
config = json.loads(Path(config_path).read_text())
return fastwan_shape(
height=height,
width=width,
num_frames=num_frames,
patch_size=tuple(config["patch_size"]),
num_heads=int(config["num_attention_heads"]),
head_dim=int(config["attention_head_dim"]),
)
def replace_tokens(shape: FastWanShape, tokens: int) -> FastWanShape:
return FastWanShape(**{**shape.__dict__, "tokens": tokens})
def median_ms(samples: list[float]) -> float:
return statistics.median(samples) * 1000.0
def benchmark_mlx_attention(shape: FastWanShape, warmup: int, iters: int) -> float:
import mlx.core as mx
q = mx.random.normal((1, shape.num_heads, shape.tokens, shape.head_dim), dtype=mx.float16)
k = mx.random.normal((1, shape.num_heads, shape.tokens, shape.head_dim), dtype=mx.float16)
v = mx.random.normal((1, shape.num_heads, shape.tokens, shape.head_dim), dtype=mx.float16)
scale = shape.head_dim**-0.5
for _ in range(warmup):
y = mx.fast.scaled_dot_product_attention(q, k, v, scale=scale)
mx.eval(y)
samples = []
for _ in range(iters):
start = time.perf_counter()
y = mx.fast.scaled_dot_product_attention(q, k, v, scale=scale)
mx.eval(y)
samples.append(time.perf_counter() - start)
return median_ms(samples)
def benchmark_mlx_linear(shape: FastWanShape, warmup: int, iters: int) -> float:
import mlx.core as mx
x = mx.random.normal((shape.tokens, shape.hidden_size), dtype=mx.float16)
w = mx.random.normal((shape.hidden_size, shape.hidden_size), dtype=mx.float16)
b = mx.zeros((shape.hidden_size, ), dtype=mx.float16)
for _ in range(warmup):
y = x @ w + b
mx.eval(y)
samples = []
for _ in range(iters):
start = time.perf_counter()
y = x @ w + b
mx.eval(y)
samples.append(time.perf_counter() - start)
return median_ms(samples)
def benchmark_torch_mps_attention(shape: FastWanShape, warmup: int, iters: int) -> float | None:
try:
import torch
import torch.nn.functional as F
except ImportError:
return None
if not torch.backends.mps.is_available():
return None
device = torch.device("mps")
q = torch.randn((1, shape.num_heads, shape.tokens, shape.head_dim), device=device, dtype=torch.float16)
k = torch.randn((1, shape.num_heads, shape.tokens, shape.head_dim), device=device, dtype=torch.float16)
v = torch.randn((1, shape.num_heads, shape.tokens, shape.head_dim), device=device, dtype=torch.float16)
for _ in range(warmup):
y = F.scaled_dot_product_attention(q, k, v)
torch.mps.synchronize()
_ = y
samples = []
for _ in range(iters):
start = time.perf_counter()
y = F.scaled_dot_product_attention(q, k, v)
torch.mps.synchronize()
_ = y
samples.append(time.perf_counter() - start)
return median_ms(samples)
def torch_to_mx(tensor) -> mx.array:
import mlx.core as mx
return mx.array(tensor.detach().cpu().float().numpy())
def weight_dtype(weight):
if isinstance(weight, QuantizedMatrix):
return weight.dequantized_dtype
return weight.dtype
_QUANT_SUPPORT_CACHE: dict[tuple[str, int | None, int | None], str | None] = {}
def quantization_support_error(spec: MLXQuantizationSpec) -> str | None:
"""Probe whether the installed MLX build supports ``spec``.
Runs a tiny ``mx.quantize`` + ``mx.quantized_matmul`` with exactly the
arguments :func:`quantize_matrix` / :func:`linear` use, so the result
reflects the real runtime path. The affine (int8/int4) modes are stable
across MLX releases, but the ``mxfp8``/``mxfp4``/``nvfp4`` mode strings
require newer MLX builds and raise otherwise. Returns ``None`` when the
mode works, else the underlying error message. Cached per spec.
"""
key = (spec.mode, spec.bits, spec.group_size)
if key not in _QUANT_SUPPORT_CACHE:
import mlx.core as mx
try:
probe_dim = max(spec.group_size or 0, 64)
weight = mx.zeros((probe_dim, probe_dim), dtype=mx.float16)
quantized = quantize_matrix(weight, spec)
y = linear(mx.zeros((1, probe_dim), dtype=mx.float16), quantized)
mx.eval(y)
_QUANT_SUPPORT_CACHE[key] = None
except Exception as exc: # noqa: BLE001 - MLX raises varied error types per backend/version.
_QUANT_SUPPORT_CACHE[key] = f"{type(exc).__name__}: {exc}"
return _QUANT_SUPPORT_CACHE[key]
def ensure_quantization_supported(spec: MLXQuantizationSpec | None) -> None:
"""Raise :class:`UnsupportedMLXQuantizationError` if ``spec`` cannot run here."""
if spec is None:
return
error = quantization_support_error(spec)
if error is None:
return
import mlx.core as mx
mlx_version = getattr(mx, "__version__", "unknown")
raise UnsupportedMLXQuantizationError(f"MLX quantization mode '{spec.label}' is not supported by the installed mlx "
f"({mlx_version}): {error}. Upgrade mlx or pick a supported mode "
f"(int8 is currently the most reliable quality/memory target).")
def quantize_matrix(weight, spec: MLXQuantizationSpec | None):
if spec is None:
return weight
import mlx.core as mx
if len(weight.shape) < 2:
return weight
q = mx.quantize(weight, group_size=spec.group_size, bits=spec.bits, mode=spec.mode)
biases = q[2] if len(q) == 3 else None
eval_args = [q[0], q[1]]
if biases is not None:
eval_args.append(biases)
mx.eval(*eval_args)
return QuantizedMatrix(
weight=q[0],
scales=q[1],
biases=biases,
spec=spec,
dequantized_dtype=weight.dtype,
)
def linear(x, weight, bias=None):
import mlx.core as mx
if isinstance(weight, QuantizedMatrix):
y = mx.quantized_matmul(
x,
weight.weight,
weight.scales,
weight.biases,
transpose=True,
group_size=weight.spec.group_size,
bits=weight.spec.bits,
mode=weight.spec.mode,
).astype(x.dtype)
else:
y = x @ weight.T
if bias is not None:
y = y + bias
return y
def _use_fast_norm() -> bool:
"""Opt-in to MLX's fused ``mx.fast`` normalization kernels.
Off by default so the numerically-explicit reference path stays the
baseline. Set ``FASTVIDEO_MLX_FAST_NORM=1`` to route LayerNorm/RMSNorm
through single fused Metal kernels (fewer intermediates, less memory
traffic) and benchmark the speedup.
"""
import os
return os.environ.get("FASTVIDEO_MLX_FAST_NORM", "0") == "1"
def layer_norm(x, weight=None, bias=None, eps: float = 1e-6):
import mlx.core as mx
if _use_fast_norm():
# Compute in fp32 (matching the reference below) so downstream dtype
# and precision are identical across call sites.
w = weight.astype(mx.float32) if weight is not None else None
b = bias.astype(mx.float32) if bias is not None else None
return mx.fast.layer_norm(x.astype(mx.float32), w, b, eps)
x_float = x.astype(mx.float32)
mean = mx.mean(x_float, axis=-1, keepdims=True)
var = mx.mean(mx.square(x_float - mean), axis=-1, keepdims=True)
y = (x_float - mean) * mx.rsqrt(var + eps)
if weight is not None:
y = y * weight
if bias is not None:
y = y + bias
return y
def rms_norm(x, weight, eps: float = 1e-6):
import mlx.core as mx
if _use_fast_norm():
return mx.fast.rms_norm(x, weight, eps)
orig_dtype = x.dtype
x_float = x.astype(mx.float32)
variance = mx.mean(mx.square(x_float), axis=-1, keepdims=True)
y = x_float * mx.rsqrt(variance + eps)
return y.astype(orig_dtype) * weight
def apply_rotary_emb(x, cos, sin, *, is_neox_style: bool = False):
"""Apply FastVideo's rotary convention to MLX tensors.
Args:
x: [batch, seq, heads, head_dim]
cos/sin: [seq, head_dim] for Wan's full-dimension rotate-pair style,
or [seq, head_dim // 2] for traditional RoPE.
"""
import mlx.core as mx
head_size = x.shape[-1]
rope_dim = cos.shape[-1]
cos = cos[None, :, None, :]
sin = sin[None, :, None, :]
x_float = x.astype(mx.float32)
if rope_dim == head_size:
x_pairs = x_float.reshape(*x.shape[:-1], -1, 2)
x_real = x_pairs[..., 0]
x_imag = x_pairs[..., 1]
x_rotated = mx.stack([-x_imag, x_real], axis=-1).reshape(*x.shape)
return (x_float * cos + x_rotated * sin).astype(x.dtype)
if is_neox_style:
x1, x2 = mx.split(x_float, 2, axis=-1)
o1 = x1 * cos - x2 * sin
o2 = x2 * cos + x1 * sin
return mx.concatenate([o1, o2], axis=-1).astype(x.dtype)
x1 = x_float[..., ::2]
x2 = x_float[..., 1::2]
o1 = x1 * cos - x2 * sin
o2 = x2 * cos + x1 * sin
return mx.stack([o1, o2], axis=-1).reshape(*x.shape).astype(x.dtype)
_WINDOWED_ATTENTION_WARNED = False
def _warn_windowed_attention_once(window: int) -> None:
"""Warn that FASTVIDEO_MLX_WINDOW degrades output on a dense-trained DiT.
Sliding-window self-attention is fast (6.6x at a +-3-frame window on 1.3B)
but these checkpoints were trained with dense attention, and restricting it
at inference produces heavy colour-block noise: structural agreement with
the dense baseline drops to 0.25 at +-3 frames and 0.03 at +-5. Sparsity of
this kind is a training-time method. Kept as a research knob, but it should
never be on by accident.
"""
global _WINDOWED_ATTENTION_WARNED
if _WINDOWED_ATTENTION_WARNED:
return
_WINDOWED_ATTENTION_WARNED = True
logger.warning(
"FASTVIDEO_MLX_WINDOW=%d enables sliding-window self-attention. These "
"checkpoints are trained dense; expect severely degraded output. This is "
"a research knob, not a speed setting — use --fast-spatial for real "
"denoise savings.",
window,
)
def gelu_tanh(x):
"""tanh-approximate GELU, as used by Wan's FFN.
``mlx.nn.gelu_approx`` is the same tanh approximation behind a fused
kernel. On the 1.3B FFN shape (32760x8960) it is bit-identical to the
expanded expression below and 3.3x faster — 28.9ms -> 8.7ms per layer,
which is 0.6s per denoise step across 30 layers.
"""
import mlx.nn as nn
return nn.gelu_approx(x)
def silu(x):
import mlx.core as mx
return x * mx.sigmoid(x)
def timestep_embedding(t, dim: int, max_period: int = 10000):
import mlx.core as mx
half = dim // 2
freqs = mx.exp(-math.log(max_period) * mx.arange(0, half, dtype=mx.float32) / half)
args = t[:, None].astype(mx.float32) * freqs[None]
embedding = mx.concatenate([mx.cos(args), mx.sin(args)], axis=-1)
if dim % 2:
embedding = mx.concatenate([embedding, mx.zeros_like(embedding[:, :1])], axis=-1)
return embedding
def scale_residual(residual, x, gate):
return residual + x * gate
def scale_residual_layer_norm_scale_shift(residual, x, gate, shift, scale, weight=None, bias=None, eps: float = 1e-6):
if isinstance(gate, int):
assert gate == 1
residual_output = residual + x
else:
residual_output = residual + x * gate
normalized = layer_norm(residual_output, weight=weight, bias=bias, eps=eps)
modulated = normalized * (1.0 + scale) + shift
return modulated, residual_output
class MLXWanT2VCrossAttention:
def __init__(self, weights: dict[str, mx.array], *, dim: int, num_heads: int, eps: float = 1e-6) -> None:
self.weights = weights
self.dim = dim
self.num_heads = num_heads
self.head_dim = dim // num_heads
self.eps = eps
def __call__(self, x, context):
import mlx.core as mx
batch = x.shape[0]
q = linear(x, self.weights["attn2.to_q.weight"], self.weights.get("attn2.to_q.bias"))
q = rms_norm(q, self.weights["attn2.norm_q.weight"], eps=self.eps).reshape(batch, -1, self.num_heads,
self.head_dim)
if context.shape[1] == 0:
attended = mx.zeros_like(q)
else:
k = linear(context, self.weights["attn2.to_k.weight"], self.weights.get("attn2.to_k.bias"))
k = rms_norm(k, self.weights["attn2.norm_k.weight"],
eps=self.eps).reshape(batch, -1, self.num_heads, self.head_dim)
v = linear(context, self.weights["attn2.to_v.weight"],
self.weights.get("attn2.to_v.bias")).reshape(batch, -1, self.num_heads, self.head_dim)
attended = mx.fast.scaled_dot_product_attention(
q.transpose(0, 2, 1, 3),
k.transpose(0, 2, 1, 3),
v.transpose(0, 2, 1, 3),
scale=self.head_dim**-0.5,
).transpose(0, 2, 1, 3)
attended = attended.reshape(batch, -1, self.dim)
return linear(attended, self.weights["attn2.to_out.weight"], self.weights.get("attn2.to_out.bias"))
class MLXWanTransformerBlock:
"""Dense T2V Wan transformer block for the experimental MLX runtime.
This mirrors the non-VSA PyTorch block for single-process dense attention.
Rotary embeddings and sequence-parallel paths are intentionally left out of
this first parity target.
"""
def __init__(self, weights: dict[str, mx.array], *, dim: int, ffn_dim: int, num_heads: int, eps: float = 1e-6):
self.weights = weights
self.dim = dim
self.ffn_dim = ffn_dim
self.num_heads = num_heads
self.head_dim = dim // num_heads
self.eps = eps
self.attn2 = MLXWanT2VCrossAttention(weights, dim=dim, num_heads=num_heads, eps=eps)
def __call__(self, hidden_states, encoder_hidden_states, temb, freqs_cis=None):
import mlx.core as mx
orig_dtype = hidden_states.dtype
e = self.weights["scale_shift_table"] + temb.astype(mx.float32)
shift_msa, scale_msa, gate_msa, c_shift_msa, c_scale_msa, c_gate_msa = mx.split(e, 6, axis=1)
norm_hidden_states = layer_norm(hidden_states.astype(mx.float32), eps=self.eps)
norm_hidden_states = (norm_hidden_states * (1.0 + scale_msa) + shift_msa).astype(orig_dtype)
query = linear(norm_hidden_states, self.weights["to_q.weight"], self.weights.get("to_q.bias"))
key = linear(norm_hidden_states, self.weights["to_k.weight"], self.weights.get("to_k.bias"))
value = linear(norm_hidden_states, self.weights["to_v.weight"], self.weights.get("to_v.bias"))
query = rms_norm(query, self.weights["norm_q.weight"],
eps=self.eps).reshape(hidden_states.shape[0], -1, self.num_heads, self.head_dim)
key = rms_norm(key, self.weights["norm_k.weight"], eps=self.eps).reshape(hidden_states.shape[0], -1,
self.num_heads, self.head_dim)
value = value.reshape(hidden_states.shape[0], -1, self.num_heads, self.head_dim)
if freqs_cis is not None:
cos, sin = freqs_cis
query = apply_rotary_emb(query, cos, sin, is_neox_style=False)
key = apply_rotary_emb(key, cos, sin, is_neox_style=False)
# Self-attention only. FASTVIDEO_MLX_WINDOW=0/unset → full SDPA (byte-identical
# to the historical path). When >0, use chunked symmetric sliding-window
# attention (see windowed_attention.py). Cross-attn (attn2) stays full.
# Optional FASTVIDEO_MLX_WINDOW_SINK (default 0) adds global sink tokens.
q_bh = query.transpose(0, 2, 1, 3) # (B, H, S, D)
k_bh = key.transpose(0, 2, 1, 3)
v_bh = value.transpose(0, 2, 1, 3)
scale = self.head_dim**-0.5
window = int(os.environ.get("FASTVIDEO_MLX_WINDOW", "0") or "0")
if window > 0:
from fastvideo.mlx_runtime.windowed_attention import windowed_attention
_warn_windowed_attention_once(window)
sink = int(os.environ.get("FASTVIDEO_MLX_WINDOW_SINK", "0") or "0")
attn_output = windowed_attention(q_bh, k_bh, v_bh, window=window, sink=sink, scale=scale)
else:
attn_output = mx.fast.scaled_dot_product_attention(q_bh, k_bh, v_bh, scale=scale)
attn_output = attn_output.transpose(0, 2, 1, 3)
attn_output = attn_output.reshape(hidden_states.shape[0], -1, self.dim)
attn_output = linear(attn_output, self.weights["to_out.weight"], self.weights.get("to_out.bias"))
norm_hidden_states, hidden_states = scale_residual_layer_norm_scale_shift(
hidden_states,
attn_output,
gate_msa,
0.0,
0.0,
weight=self.weights["self_attn_residual_norm.norm.weight"],
bias=self.weights["self_attn_residual_norm.norm.bias"],
eps=self.eps,
)
norm_hidden_states = norm_hidden_states.astype(orig_dtype)
hidden_states = hidden_states.astype(orig_dtype)
attn_output = self.attn2(norm_hidden_states, encoder_hidden_states)
norm_hidden_states, hidden_states = scale_residual_layer_norm_scale_shift(
hidden_states,
attn_output,
1,
c_shift_msa,
c_scale_msa,
eps=self.eps,
)
norm_hidden_states = norm_hidden_states.astype(orig_dtype)
hidden_states = hidden_states.astype(orig_dtype)
ff_output = linear(norm_hidden_states, self.weights["ffn.fc_in.weight"], self.weights.get("ffn.fc_in.bias"))
ff_output = gelu_tanh(ff_output)
ff_output = linear(ff_output, self.weights["ffn.fc_out.weight"], self.weights.get("ffn.fc_out.bias"))
hidden_states = scale_residual(hidden_states, ff_output, c_gate_msa)
return hidden_states.astype(orig_dtype)
def mlx_block_weights_from_torch(torch_block) -> dict[str, mx.array]:
return {name: torch_to_mx(value) for name, value in torch_block.state_dict().items()}
class MLXWanDiT:
"""Experimental FP16 Wan/FastWan DiT forward path in MLX."""
def __init__(
self,
weights: dict[str, mx.array],
blocks: list[MLXWanTransformerBlock],
config: dict,
*,
compile: bool = False,
) -> None:
import os
self.weights = weights
self.blocks = blocks
self.config = config
self.num_heads = int(config["num_attention_heads"])
self.head_dim = int(config["attention_head_dim"])
self.hidden_size = self.num_heads * self.head_dim
self.ffn_dim = int(config["ffn_dim"])
self.in_channels = int(config["in_channels"])
self.out_channels = int(config["out_channels"])
self.patch_size = tuple(config["patch_size"])
self.freq_dim = int(config["freq_dim"])
# Opt-in graph fusion. With fixed weights and static shapes, the whole
# denoise-step forward is a pure function of (latents, timestep) -- a
# good mx.compile target. Off by default so the eager path stays the
# baseline; enable via constructor or FASTVIDEO_MLX_COMPILE=1 and verify
# with the benchmark's SSIM ~= 1.0 check.
self._enable_compile = compile or os.environ.get("FASTVIDEO_MLX_COMPILE", "0") == "1"
self._compiled_forward: Callable[..., Any] | None = None
self._compiled_signature: tuple | None = None
def patch_embed(self, hidden_states):
batch, channels, frames, height, width = hidden_states.shape
pt, ph, pw = self.patch_size
patch_dim = channels * pt * ph * pw
x = hidden_states.reshape(batch, channels, frames // pt, pt, height // ph, ph, width // pw, pw)
x = x.transpose(0, 2, 4, 6, 1, 3, 5, 7).reshape(batch, -1, patch_dim)
return linear(x, self.weights["patch_embedding.weight"], self.weights.get("patch_embedding.bias"))
def condition(self, timestep, encoder_hidden_states):
t_freq = timestep_embedding(timestep, self.freq_dim).astype(
weight_dtype(self.weights["condition_embedder.time_embedder.linear_1.weight"]))
temb = linear(
t_freq,
self.weights["condition_embedder.time_embedder.linear_1.weight"],
self.weights["condition_embedder.time_embedder.linear_1.bias"],
)
temb = silu(temb)
temb = linear(
temb,
self.weights["condition_embedder.time_embedder.linear_2.weight"],
self.weights["condition_embedder.time_embedder.linear_2.bias"],
)
timestep_proj = silu(temb)
timestep_proj = linear(
timestep_proj,
self.weights["condition_embedder.time_proj.weight"],
self.weights["condition_embedder.time_proj.bias"],
).reshape(timestep.shape[0], 6, self.hidden_size)
encoder_hidden_states = linear(
encoder_hidden_states,
self.weights["condition_embedder.text_embedder.linear_1.weight"],
self.weights["condition_embedder.text_embedder.linear_1.bias"],
)
encoder_hidden_states = gelu_tanh(encoder_hidden_states)
encoder_hidden_states = linear(
encoder_hidden_states,
self.weights["condition_embedder.text_embedder.linear_2.weight"],
self.weights["condition_embedder.text_embedder.linear_2.bias"],
)
return temb, timestep_proj, encoder_hidden_states
def output(self, hidden_states, temb, *, batch: int, frames: int, height: int, width: int):
pt, ph, pw = self.patch_size
post_patch_frames = frames // pt
post_patch_height = height // ph
post_patch_width = width // pw
shift, scale = mx_split_two(self.weights["scale_shift_table"] + temb[:, None, :], axis=1)
hidden_states = layer_norm(hidden_states, eps=float(self.config["eps"])) * (1.0 + scale) + shift
hidden_states = hidden_states.astype(weight_dtype(self.weights["proj_out.weight"]))
hidden_states = linear(hidden_states, self.weights["proj_out.weight"], self.weights["proj_out.bias"])
hidden_states = hidden_states.reshape(
batch,
post_patch_frames,
post_patch_height,
post_patch_width,
pt,
ph,
pw,
self.out_channels,
)
hidden_states = hidden_states.transpose(0, 7, 1, 4, 2, 5, 3, 6)
return hidden_states.reshape(batch, self.out_channels, frames, height, width)
def _forward(self, hidden_states, encoder_hidden_states, timestep, cos, sin):
"""Pure forward used both eagerly and as the mx.compile target.
``cos``/``sin`` are passed as separate array args (rather than a tuple)
so the function traces cleanly under mx.compile.
"""
batch, _, frames, height, width = hidden_states.shape
freqs_cis = (cos, sin) if cos is not None else None
hidden_states = self.patch_embed(hidden_states)
temb, timestep_proj, encoder_hidden_states = self.condition(timestep, encoder_hidden_states)
for block in self.blocks:
hidden_states = block(hidden_states, encoder_hidden_states, timestep_proj, freqs_cis=freqs_cis)
return self.output(hidden_states, temb, batch=batch, frames=frames, height=height, width=width)
def __call__(self, hidden_states, encoder_hidden_states, timestep, freqs_cis):
cos, sin = freqs_cis if freqs_cis is not None else (None, None)
if self._enable_compile and cos is not None:
import mlx.core as mx
# mx.compile keeps one traced graph per input signature, and each
# graph pins its own materialization of the quantized weights. The
# two-pass modes (--refine) call the DiT at a second resolution, so
# keeping both graphs alive doubles resident DiT memory: 14B refine
# peaked at 34.7 GiB instead of 20.8 GiB. Retire the previous graph
# when the signature changes; the retrace costs far less than a
# second copy of the weights.
signature = (hidden_states.shape, encoder_hidden_states.shape, timestep.shape)
if self._compiled_forward is not None and signature != self._compiled_signature:
self._compiled_forward = None
self._compiled_signature = None
mx.clear_cache()
if self._compiled_forward is None:
self._compiled_forward = mx.compile(self._forward)
self._compiled_signature = signature
compiled_forward = self._compiled_forward
try:
return compiled_forward(hidden_states, encoder_hidden_states, timestep, cos, sin)
except Exception as exc: # noqa: BLE001 - some quant graphs may not trace; fall back to eager.
logger.warning("mx.compile forward failed (%s); falling back to eager execution.", exc)
self._enable_compile = False
self._compiled_forward = None
self._compiled_signature = None
return self._forward(hidden_states, encoder_hidden_states, timestep, cos, sin)
def mx_split_two(x, *, axis: int):
import mlx.core as mx
left, right = mx.split(x, 2, axis=axis)
return left, right
def _load_safetensor_value(handle, name: str):
return handle.get_tensor(name)
def _load_mx_array_from_safetensor(handle, name: str, dtype):
"""Load a safetensors value and cast before creating the MLX array.
The FastWan Diffusers checkpoint is fp32. Creating an MLX array first and
then casting it to fp16 briefly materializes a large fp32 MLX allocation.
Casting the CPU tensor before crossing into MLX keeps the transient GPU-side
footprint lower.
"""
import mlx.core as mx
import torch
tensor = handle.get_tensor(name)
if dtype == mx.float16:
tensor = tensor.to(torch.float16)
elif dtype == mx.float32:
tensor = tensor.to(torch.float32)
elif dtype == mx.bfloat16:
# NumPy has no bfloat16, so bridge through fp32 and cast on-device below.
tensor = tensor.to(torch.float32)
array = mx.array(tensor.numpy())
del tensor
if dtype is not None and array.dtype != dtype:
array = array.astype(dtype)
mx.eval(array)
return array
def _eval_loaded_weight(value) -> None:
import mlx.core as mx
if isinstance(value, QuantizedMatrix):
eval_args = [value.weight, value.scales]
if value.biases is not None:
eval_args.append(value.biases)
mx.eval(*eval_args)
else:
mx.eval(value)
# Diffusers-to-FastVideo key mapping for WanTransformerBlock weights.
# Shared by both MLX and torch block loaders to keep mappings synchronized.
_WAN_BLOCK_KEY_MAP = {
"scale_shift_table": "scale_shift_table",
"attn1.to_q.weight": "to_q.weight",
"attn1.to_q.bias": "to_q.bias",
"attn1.to_k.weight": "to_k.weight",
"attn1.to_k.bias": "to_k.bias",
"attn1.to_v.weight": "to_v.weight",
"attn1.to_v.bias": "to_v.bias",
"attn1.to_out.0.weight": "to_out.weight",
"attn1.to_out.0.bias": "to_out.bias",
"attn1.norm_q.weight": "norm_q.weight",
"attn1.norm_k.weight": "norm_k.weight",
"attn2.to_q.weight": "attn2.to_q.weight",
"attn2.to_q.bias": "attn2.to_q.bias",
"attn2.to_k.weight": "attn2.to_k.weight",
"attn2.to_k.bias": "attn2.to_k.bias",
"attn2.to_v.weight": "attn2.to_v.weight",
"attn2.to_v.bias": "attn2.to_v.bias",
"attn2.to_out.0.weight": "attn2.to_out.weight",
"attn2.to_out.0.bias": "attn2.to_out.bias",
"attn2.norm_q.weight": "attn2.norm_q.weight",
"attn2.norm_k.weight": "attn2.norm_k.weight",
"ffn.net.0.proj.weight": "ffn.fc_in.weight",
"ffn.net.0.proj.bias": "ffn.fc_in.bias",
"ffn.net.2.weight": "ffn.fc_out.weight",
"ffn.net.2.bias": "ffn.fc_out.bias",
"norm2.weight": "self_attn_residual_norm.norm.weight",
"norm2.bias": "self_attn_residual_norm.norm.bias",
}
def mlx_block_weights_from_diffusers_safetensors(
checkpoint_path: str | Path,
*,
block_index: int = 0,
quantization: str | MLXQuantizationSpec | None = None,
dtype=None,
) -> dict[str, mx.array]:
"""Load one Diffusers-format Wan block into the MLX dense-block key layout."""
from safetensors import safe_open
prefix = f"blocks.{block_index}."
key_map = _WAN_BLOCK_KEY_MAP
spec = MLXQuantizationSpec.from_name(quantization) if (quantization is None
or isinstance(quantization, str)) else quantization
ensure_quantization_supported(spec)
matrix_targets = {target for target in key_map.values() if target.endswith(".weight") and "norm" not in target}
weights = {}
with safe_open(str(checkpoint_path), framework="pt", device="cpu") as handle:
available = set(handle.keys())
for source_name, target_name in key_map.items():
full = prefix + source_name
if full not in available:
# Biases are optional: e.g. Wan2.1-14B has bias-free attention/FFN.
# The block forward already fetches biases via ``.get(...)``.
if source_name.endswith(".bias"):
continue
raise KeyError(f"missing required block weight: {full}")
array = _load_mx_array_from_safetensor(handle, full, dtype)
loaded = quantize_matrix(array, spec) if target_name in matrix_targets else array
_eval_loaded_weight(loaded)
weights[target_name] = loaded
del array
return weights
def mlx_dit_from_diffusers_safetensors(
checkpoint_path: str | Path,
config_path: str | Path,
*,
dtype: str = "fp16",
num_blocks: int | None = None,
quantization: str | MLXQuantizationSpec | None = None,
compile: bool = False,
) -> MLXWanDiT:
import mlx.core as mx
from safetensors import safe_open
config = json.loads(Path(config_path).read_text())
total_blocks = int(config["num_layers"])
if num_blocks is None:
num_blocks = total_blocks
cast_dtype = {"fp16": mx.float16, "bf16": mx.bfloat16, "fp32": mx.float32}[dtype]
spec = MLXQuantizationSpec.from_name(quantization) if (quantization is None
or isinstance(quantization, str)) else quantization
ensure_quantization_supported(spec)
top_level_names = [
"patch_embedding.weight",
"patch_embedding.bias",
"condition_embedder.time_embedder.linear_1.weight",
"condition_embedder.time_embedder.linear_1.bias",
"condition_embedder.time_embedder.linear_2.weight",
"condition_embedder.time_embedder.linear_2.bias",
"condition_embedder.time_proj.weight",
"condition_embedder.time_proj.bias",
"condition_embedder.text_embedder.linear_1.weight",
"condition_embedder.text_embedder.linear_1.bias",
"condition_embedder.text_embedder.linear_2.weight",
"condition_embedder.text_embedder.linear_2.bias",
"scale_shift_table",
"proj_out.weight",
"proj_out.bias",
]
weights = {}
with safe_open(str(checkpoint_path), framework="pt", device="cpu") as handle:
available = set(handle.keys())
for name in top_level_names:
if name not in available:
if name.endswith(".bias"):
continue
raise KeyError(f"missing required weight: {name}")
array = _load_mx_array_from_safetensor(handle, name, cast_dtype)
if name == "patch_embedding.weight":
array = array.reshape(int(config["num_attention_heads"]) * int(config["attention_head_dim"]), -1)
if name.endswith(".weight") and name not in {"scale_shift_table"}:
loaded = quantize_matrix(array, spec)
else:
loaded = array
_eval_loaded_weight(loaded)
weights[name] = loaded
del array
blocks = []
for block_index in range(num_blocks):
block_weights = mlx_block_weights_from_diffusers_safetensors(
checkpoint_path,
block_index=block_index,
quantization=spec,
dtype=cast_dtype,
)
block_weights = {
name: (value if isinstance(value, QuantizedMatrix) else value.astype(cast_dtype))
for name, value in block_weights.items()
}
for value in block_weights.values():
_eval_loaded_weight(value)
blocks.append(
MLXWanTransformerBlock(
block_weights,
dim=int(config["num_attention_heads"]) * int(config["attention_head_dim"]),
ffn_dim=int(config["ffn_dim"]),
num_heads=int(config["num_attention_heads"]),
eps=float(config["eps"]),
))
return MLXWanDiT(weights, blocks, config, compile=compile)
def torch_block_state_from_diffusers_safetensors(
checkpoint_path: str | Path,
*,
block_index: int = 0,
) -> dict[str, torch.Tensor]:
"""Load one Diffusers-format Wan block into FastVideo's dense block keys."""
from safetensors import safe_open
prefix = f"blocks.{block_index}."
key_map = _WAN_BLOCK_KEY_MAP
state = {}
with safe_open(str(checkpoint_path), framework="pt", device="cpu") as handle:
available = set(handle.keys())
for source_name, target_name in key_map.items():
full = prefix + source_name
if full not in available:
# Biases are optional: e.g. Wan2.1-14B has bias-free attention/FFN.
if source_name.endswith(".bias"):
continue
raise KeyError(f"missing required block weight: {full}")
state[target_name] = handle.get_tensor(full).float()
return state
+154
View File
@@ -0,0 +1,154 @@
# SPDX-License-Identifier: Apache-2.0
"""Pixel-space spatial resampling for decoded MLX Wan frames.
Spatial fast mode denoises on a smaller latent grid and has to get back to
the requested output size. That resize belongs *here* — after the VAE
decode — and not in latent space.
A Wan latent cell is a learned code for an 8x8 (Wan2.1) or 16x16 (Wan2.2)
pixel block, not a low-pass sample of the image. Linearly blending two
adjacent codes does not produce the code of the blended blocks; it produces
a vector the decoder was never trained on. The decoder answers with smeared,
ringing texture laid over otherwise-correct structure — the silhouette
survives, the detail turns to haze. Measured on Wan2.1-1.3B at 480x832, a
2x bilinear latent upsample destroys 62% of the latent's high-frequency
energy while leaving its overall magnitude intact, which is exactly the
signature of that veil.
Resampling decoded RGB frames has none of that problem: an image *is* a
sampled 2-D signal, so Lanczos/cubic interpolation is the operation it was
defined for. The result is soft — it carries stage-1's real detail budget
and no more — but it is clean and coherent.
"""
from __future__ import annotations
from collections.abc import Iterable
import numpy as np
# Pixel-space interpolation kernels, best-quality first. ``lanczos`` is the
# default: it holds edges better than cubic at 2x with no visible ringing on
# decoder output, which is already band-limited.
PIXEL_UPSAMPLE_MODES = ("lanczos", "cubic", "bilinear", "nearest")
DEFAULT_PIXEL_UPSAMPLE_MODE = "lanczos"
def _interpolation_flag(mode: str) -> int:
"""
Map a pixel upsample mode name onto its OpenCV interpolation flag.
Parameters:
mode (str): One of :data:`PIXEL_UPSAMPLE_MODES`.
Returns:
int: The matching ``cv2.INTER_*`` flag.
Raises:
ValueError: If the mode is not a supported pixel upsample mode.
"""
import cv2
flags = {
"lanczos": cv2.INTER_LANCZOS4,
"cubic": cv2.INTER_CUBIC,
"bilinear": cv2.INTER_LINEAR,
"nearest": cv2.INTER_NEAREST,
}
try:
return flags[mode]
except KeyError:
raise ValueError(f"Unsupported pixel upsample mode: {mode!r} "
f"(expected one of {', '.join(PIXEL_UPSAMPLE_MODES)})") from None
def unsharp(frame: np.ndarray, amount: float) -> np.ndarray:
"""Light unsharp mask, used to counter resampling / optical-flow softening.
Parameters:
frame (np.ndarray): HxWx3 uint8 RGB frame.
amount (float): Strength; ``0`` returns the frame unchanged.
Returns:
np.ndarray: A new frame; the input is never modified in place.
"""
if amount <= 0.0:
return frame
import cv2
blur = cv2.GaussianBlur(frame, (0, 0), 1.0)
return cv2.addWeighted(frame, 1.0 + amount, blur, -amount, 0)
def upsample_frame(
frame: np.ndarray,
*,
width: int,
height: int,
mode: str = DEFAULT_PIXEL_UPSAMPLE_MODE,
sharpen: float = 0.0,
) -> np.ndarray:
"""
Resample one decoded RGB frame to the target pixel size.
Parameters:
frame (np.ndarray): HxWx3 uint8 RGB frame.
width (int): Target width in pixels.
height (int): Target height in pixels.
mode (str): Interpolation kernel, one of :data:`PIXEL_UPSAMPLE_MODES`.
sharpen (float): Unsharp strength applied after the resize.
Returns:
np.ndarray: A new frame at ``height x width``; already-correct sizes
are still passed through ``sharpen``.
Raises:
ValueError: If the frame is not HxWx3, or the target size is not positive.
"""
import cv2
array = np.asarray(frame)
if array.ndim != 3 or array.shape[2] != 3:
raise ValueError(f"frame must have shape HxWx3, got {array.shape}")
if width <= 0 or height <= 0:
raise ValueError(f"target size must be positive, got {width}x{height}")
if array.dtype != np.uint8:
array = np.clip(array, 0, 255).astype(np.uint8)
if (array.shape[0], array.shape[1]) != (height, width):
array = cv2.resize(array, (width, height), interpolation=_interpolation_flag(mode))
return unsharp(array, sharpen)
def upsample_frames(
frames: Iterable[np.ndarray],
*,
width: int,
height: int,
mode: str = DEFAULT_PIXEL_UPSAMPLE_MODE,
sharpen: float = 0.0,
) -> list[np.ndarray]:
"""
Resample every decoded frame to the target pixel size.
Parameters:
frames (Iterable[np.ndarray]): Decoded HxWx3 uint8 RGB frames.
width (int): Target width in pixels.
height (int): Target height in pixels.
mode (str): Interpolation kernel, one of :data:`PIXEL_UPSAMPLE_MODES`.
sharpen (float): Unsharp strength applied after each resize.
Returns:
list[np.ndarray]: New frames at the target size, in input order.
"""
return [upsample_frame(frame, width=width, height=height, mode=mode, sharpen=sharpen) for frame in frames]
__all__ = [
"DEFAULT_PIXEL_UPSAMPLE_MODE",
"PIXEL_UPSAMPLE_MODES",
"unsharp",
"upsample_frame",
"upsample_frames",
]
+243
View File
@@ -0,0 +1,243 @@
# SPDX-License-Identifier: Apache-2.0
"""Memory-tier helpers for Apple Silicon MLX/MPS experiments.
macOS does not expose a perfect "pretend this machine only has 16 GB unified
memory" switch. MLX can cap the allocator used by the Apple-native DiT path,
and PyTorch MPS exposes process-level watermark environment variables for the
hybrid prompt/decode stages. Applying both gives benchmark and generation
entrypoints a practical, explicit way to exercise memory-tier presets.
"""
from __future__ import annotations
import argparse
import gc
import os
from dataclasses import dataclass, field
from typing import Any
GIB = 1024**3
@dataclass(frozen=True)
class AppliedMemoryLimits:
"""Memory limits applied for one Apple Silicon benchmark/generation process."""
mlx_memory_limit_gib: float | None = None
mlx_cache_limit_gib: float | None = None
mlx_disable_cache: bool = False
mlx_wired_limit_gib: float | None = None
torch_mps_high_watermark_ratio: float | None = None
torch_mps_low_watermark_ratio: float | None = None
applied_bytes: dict[str, int] = field(default_factory=dict)
previous_bytes: dict[str, int] = field(default_factory=dict)
errors: dict[str, str] = field(default_factory=dict)
def as_metrics(self) -> dict[str, int | float | str | bool | None]:
"""Flatten the configured memory limits, applied values, previous values, and errors into a metrics dictionary.
Returns:
dict[str, int | float | str | bool | None]: Metrics keyed by limit names and their corresponding values.
"""
metrics: dict[str, int | float | str | bool | None] = {
"mlx_memory_limit_gib": self.mlx_memory_limit_gib,
"mlx_cache_limit_gib": self.mlx_cache_limit_gib,
"mlx_disable_cache": self.mlx_disable_cache,
"mlx_wired_limit_gib": self.mlx_wired_limit_gib,
"torch_mps_high_watermark_ratio": self.torch_mps_high_watermark_ratio,
"torch_mps_low_watermark_ratio": self.torch_mps_low_watermark_ratio,
}
for name, value in self.applied_bytes.items():
metrics[f"{name}_bytes"] = value
for name, value in self.previous_bytes.items():
metrics[f"previous_{name}_bytes"] = value
for name, error in self.errors.items():
metrics[f"{name}_error"] = error
return metrics
def gib_to_bytes(value: float | None) -> int | None:
"""
Convert a positive memory limit from GiB to bytes.
Parameters:
value (float | None): Memory limit in GiB, or `None` when unset.
Returns:
int | None: The memory limit in bytes, or `None` when no limit is provided.
Raises:
ValueError: If `value` is zero or negative.
"""
if value is None:
return None
if value <= 0:
raise ValueError(f"Memory limit must be positive GiB, got {value}")
return int(value * GIB)
def cleanup_mlx(mx_module: Any | None = None) -> None:
"""Collect unreachable MLX objects, then release their allocator cache."""
if mx_module is None:
import mlx.core as mx
mx_module = mx
gc.collect()
mx_module.clear_cache()
def cleanup_torch_mps(torch_module: Any | None = None) -> None:
"""Collect unreachable Torch objects, then release the MPS allocator cache."""
if torch_module is None:
import torch
torch_module = torch
gc.collect()
if torch_module.backends.mps.is_available():
torch_module.mps.empty_cache()
def _set_mps_env(name: str, value: float | None) -> float | None:
"""Set a PyTorch MPS watermark environment variable.
Parameters:
name (str): Name of the environment variable to set.
value (float | None): Watermark ratio, or `None` to leave the variable unchanged.
Returns:
float | None: The configured watermark ratio, or `None` when no value is provided.
Raises:
ValueError: If `value` is negative.
"""
if value is None:
return None
if value < 0:
raise ValueError(f"{name} must be non-negative, got {value}")
os.environ[name] = str(value)
return value
def apply_memory_limits(
*,
mlx_memory_limit_gib: float | None = None,
mlx_cache_limit_gib: float | None = None,
mlx_disable_cache: bool = False,
mlx_wired_limit_gib: float | None = None,
torch_mps_high_watermark_ratio: float | None = None,
torch_mps_low_watermark_ratio: float | None = None,
mx_module: Any | None = None,
) -> AppliedMemoryLimits:
"""Apply optional MLX allocator limits and PyTorch MPS watermarks.
PyTorch reads MPS watermark variables when the MPS backend initializes, so
call this before importing PyTorch. Specifying only a high watermark sets the
low watermark to ``0.0``. MLX limit-setting failures are recorded in the
result and do not prevent other limits from being applied.
Parameters:
mlx_memory_limit_gib (float | None): Maximum MLX memory in GiB.
mlx_cache_limit_gib (float | None): Maximum MLX cache size in GiB.
mlx_disable_cache (bool): Whether to disable the MLX cache.
mlx_wired_limit_gib (float | None): Maximum MLX wired memory in GiB.
torch_mps_high_watermark_ratio (float | None): PyTorch MPS high watermark
ratio.
torch_mps_low_watermark_ratio (float | None): PyTorch MPS low watermark
ratio.
Returns:
AppliedMemoryLimits: Configured values, applied and previous MLX byte
limits, MPS watermark values, and per-limit errors.
"""
if torch_mps_high_watermark_ratio is not None and torch_mps_low_watermark_ratio is None:
torch_mps_low_watermark_ratio = 0.0
high = _set_mps_env("PYTORCH_MPS_HIGH_WATERMARK_RATIO", torch_mps_high_watermark_ratio)
low = _set_mps_env("PYTORCH_MPS_LOW_WATERMARK_RATIO", torch_mps_low_watermark_ratio)
memory_bytes = gib_to_bytes(mlx_memory_limit_gib)
cache_bytes = 0 if mlx_disable_cache else gib_to_bytes(mlx_cache_limit_gib)
wired_bytes = gib_to_bytes(mlx_wired_limit_gib)
applied: dict[str, int] = {}
previous: dict[str, int] = {}
errors: dict[str, str] = {}
if memory_bytes is not None or cache_bytes is not None or wired_bytes is not None:
if mx_module is None:
import mlx.core as mx
mx_module = mx
# Apply each limit independently; record failures without stopping.
limits = [
("mlx_memory_limit", memory_bytes, mx_module.set_memory_limit),
("mlx_cache_limit", cache_bytes, mx_module.set_cache_limit),
("mlx_wired_limit", wired_bytes, mx_module.set_wired_limit),
]
for name, value, setter in limits:
if value is not None:
try:
previous[name] = int(setter(value))
applied[name] = value
except Exception as exc: # noqa: BLE001 - macOS/system-limit dependent.
errors[name] = f"{type(exc).__name__}: {exc}"
return AppliedMemoryLimits(
mlx_memory_limit_gib=mlx_memory_limit_gib,
mlx_cache_limit_gib=mlx_cache_limit_gib,
mlx_disable_cache=mlx_disable_cache,
mlx_wired_limit_gib=mlx_wired_limit_gib,
torch_mps_high_watermark_ratio=high,
torch_mps_low_watermark_ratio=low,
applied_bytes=applied,
previous_bytes=previous,
errors=errors,
)
def add_memory_limit_args(
parser: argparse.ArgumentParser,
*,
mlx_memory_limit_gib: float | None = None,
mlx_cache_limit_gib: float | None = None,
mlx_disable_cache: bool = False,
mlx_wired_limit_gib: float | None = None,
torch_mps_high_watermark_ratio: float | None = None,
torch_mps_low_watermark_ratio: float | None = None,
) -> None:
"""
Add configurable Apple Silicon memory-limit options to an argument parser.
Parameters:
parser (argparse.ArgumentParser): Parser to which the options are added.
mlx_memory_limit_gib (float | None): Default MLX memory limit in GiB.
mlx_cache_limit_gib (float | None): Default MLX cache limit in GiB.
mlx_disable_cache (bool): Whether the cache limit defaults to zero.
mlx_wired_limit_gib (float | None): Default MLX wired-memory limit in GiB.
torch_mps_high_watermark_ratio (float | None): Default PyTorch MPS high-watermark ratio.
torch_mps_low_watermark_ratio (float | None): Default PyTorch MPS low-watermark ratio.
"""
parser.add_argument("--mlx-memory-limit-gib",
type=float,
default=mlx_memory_limit_gib,
help="Set MLX memory limit in GiB for memory-tier testing (DiT path).")
parser.add_argument("--mlx-cache-limit-gib",
type=float,
default=mlx_cache_limit_gib,
help="Set MLX cache limit in GiB. Use --mlx-disable-cache to force 0.")
parser.add_argument("--mlx-disable-cache",
action="store_true",
default=mlx_disable_cache,
help="Set MLX cache limit to 0 for stricter memory-tier tests.")
parser.add_argument("--mlx-wired-limit-gib",
type=float,
default=mlx_wired_limit_gib,
help="Set MLX wired-memory limit in GiB where supported by macOS/MLX.")
parser.add_argument("--torch-mps-high-watermark-ratio",
type=float,
default=torch_mps_high_watermark_ratio,
help="Set PYTORCH_MPS_HIGH_WATERMARK_RATIO before importing torch.")
parser.add_argument("--torch-mps-low-watermark-ratio",
type=float,
default=torch_mps_low_watermark_ratio,
help="Set PYTORCH_MPS_LOW_WATERMARK_RATIO before importing torch.")
+129
View File
@@ -0,0 +1,129 @@
# SPDX-License-Identifier: Apache-2.0
"""Best-effort prompt-embedding cache shared by the MLX entrypoints."""
from __future__ import annotations
import hashlib
import io
import json
import logging
import tempfile
from pathlib import Path
import numpy as np
logger = logging.getLogger(__name__)
def fingerprint_digest(fingerprint: dict[str, object]) -> str:
payload = json.dumps(fingerprint, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def text_encoder_fingerprint(model_root: Path) -> dict[str, object]:
"""Return a cheap identity for the tokenizer and text-encoder files."""
root = model_root.resolve()
components = [path for name in ("tokenizer", "text_encoder") if (path := root / name).is_dir()]
scan_roots = components or [root]
files: list[list[object]] = []
complete = True
try:
for scan_root in scan_roots:
for path in sorted(scan_root.rglob("*")):
try:
if not path.is_file():
continue
stat = path.stat()
files.append([
path.relative_to(root).as_posix(),
stat.st_size,
stat.st_mtime_ns,
stat.st_ctime_ns,
])
except OSError:
complete = False
except OSError:
complete = False
# ponytail: metadata avoids hashing multi-GB weights; use a model manifest
# if supported workflows ever preserve size, mtime, and ctime while mutating.
return {"root": str(root), "files": files, "complete": complete}
def prompt_cache_meta_path(cache_path: Path) -> Path:
return cache_path.with_suffix(cache_path.suffix + ".json")
def _fingerprint_is_complete(fingerprint: dict[str, object]) -> bool:
text_encoder = fingerprint.get("text_encoder")
return not isinstance(text_encoder, dict) or text_encoder.get("complete") is not False
def load_prompt_cache(
cache_path: Path | None,
fingerprint: dict[str, object],
) -> np.ndarray | None:
"""Load a matching cache entry, treating every cache failure as a miss."""
if cache_path is None or not _fingerprint_is_complete(fingerprint):
return None
try:
metadata = json.loads(prompt_cache_meta_path(cache_path).read_text())
if not isinstance(metadata, dict):
return None
if metadata.get("fingerprint_sha256") != fingerprint_digest(fingerprint):
return None
payload = cache_path.read_bytes()
if metadata.get("data_sha256") != hashlib.sha256(payload).hexdigest():
return None
array = np.load(io.BytesIO(payload), allow_pickle=False)
return array if isinstance(array, np.ndarray) else None
except (EOFError, OSError, UnicodeError, ValueError) as exc:
logger.info("Prompt cache read skipped for %s: %s", cache_path, exc)
return None
def _atomic_write(path: Path, payload: bytes) -> None:
temp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as handle:
temp_path = Path(handle.name)
handle.write(payload)
temp_path.replace(path)
finally:
if temp_path is not None:
temp_path.unlink(missing_ok=True)
def save_prompt_cache(
cache_path: Path | None,
embeds: np.ndarray,
fingerprint: dict[str, object],
) -> bool:
"""Atomically publish an integrity-bound cache entry when possible."""
if cache_path is None or not _fingerprint_is_complete(fingerprint):
return False
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
buffer = io.BytesIO()
np.save(buffer, np.asarray(embeds), allow_pickle=False)
payload = buffer.getvalue()
metadata = (json.dumps(
{
"fingerprint_sha256": fingerprint_digest(fingerprint),
"data_sha256": hashlib.sha256(payload).hexdigest(),
"fingerprint": fingerprint,
},
indent=2) + "\n").encode("utf-8")
# Publish data first. Until metadata follows, old metadata's data digest
# makes the torn pair a harmless miss rather than a stale cache hit.
_atomic_write(cache_path, payload)
_atomic_write(prompt_cache_meta_path(cache_path), metadata)
return True
except (OSError, TypeError, ValueError) as exc:
logger.info("Prompt cache write skipped for %s: %s", cache_path, exc)
return False
+454
View File
@@ -0,0 +1,454 @@
# SPDX-License-Identifier: Apache-2.0
"""Local prompt enrichment for the MLX Wan runtime (H3 Context-IR-style).
Wan's training captions are long and cinematic; short user prompts leave
quality on the table. This module expands a raw prompt into Wan-style
shot language **on device** — no remote API, no training.
Backends (first match wins):
1. **mlx-lm** — optional local LLM (``--enhance-prompt-model``).
2. **template** — deterministic cinematic expansion (always available).
System-prompt contract matches the streaming server's enhancer defaults
in ``fastvideo/entrypoints/streaming/prompt/enhancer.py`` so remote and
local paths stay interchangeable.
"""
from __future__ import annotations
import hashlib
import json
import re
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from fastvideo.logger import init_logger
logger = init_logger(__name__)
# Keep in lockstep with streaming PromptEnhancer defaults (enhance op).
DEFAULT_ENHANCE_SYSTEM_PROMPT = ("You are a prompt enhancer for cinematic video generation. Given "
"a user prompt, produce an enhanced prompt that is more vivid, "
"specific, and concrete. Keep the subject intact; add lighting, "
"camera, and motion detail. Reply with just the enhanced prompt.")
# Small default that fits 16 GB Macs alongside the 1.3B DiT when the user
# opts into mlx-lm. Override with --enhance-prompt-model.
DEFAULT_MLX_LM_MODEL = "mlx-community/Qwen2.5-0.5B-Instruct-4bit"
_CAMERA_CUES = (
"cinematic",
"camera",
"lens",
"shot",
"bokeh",
"dolly",
"tracking",
"close-up",
"wide shot",
"handheld",
"steadicam",
)
_LIGHT_CUES = (
"light",
"lighting",
"sun",
"golden hour",
"neon",
"rim light",
"softbox",
"overcast",
"moonlight",
"volumetric",
)
_MOTION_CUES = (
"moving",
"motion",
"walk",
"run",
"flies",
"flying",
"drifts",
"sails",
"flows",
"pan",
"tilt",
"zoom",
)
@dataclass(frozen=True)
class EnhanceResult:
"""Outcome of a prompt enrichment call."""
original: str
enhanced: str
backend: str
elapsed_s: float
model: str | None = None
@property
def changed(self) -> bool:
"""Indicates whether the enhanced prompt differs from the original after trimming surrounding whitespace.
Returns:
bool: `True` if the prompts differ, `False` otherwise.
"""
return self.enhanced.strip() != self.original.strip()
def _normalize_user_prompt(prompt: str) -> str:
"""
Normalize a user prompt for enhancement.
Parameters:
prompt (str): User-provided prompt text.
Returns:
str: The prompt with leading and trailing whitespace removed and internal whitespace collapsed.
Raises:
ValueError: If the prompt is empty after whitespace normalization.
"""
text = " ".join(prompt.strip().split())
if not text:
raise ValueError("prompt must be non-empty")
return text
def _already_rich(prompt: str) -> bool:
"""
Determine whether a prompt already contains substantial camera and lighting detail.
Returns:
bool: `true` if the prompt is at least 160 characters long and includes camera and lighting cues, `false` otherwise.
"""
lower = prompt.lower()
has_camera = any(c in lower for c in _CAMERA_CUES)
has_light = any(c in lower for c in _LIGHT_CUES)
return len(prompt) >= 160 and has_camera and has_light
def enhance_prompt_template(prompt: str) -> str:
"""
Expand a prompt with cinematic camera, lighting, motion, and visual-quality details.
Rich prompts are preserved, while thinner prompts receive deterministic enhancements
without changing their subject.
Returns:
str: The original or expanded prompt with normalized whitespace and punctuation.
"""
text = _normalize_user_prompt(prompt)
if _already_rich(text):
return text
lower = text.lower()
parts = [text.rstrip(".")]
if not any(c in lower for c in _CAMERA_CUES):
parts.append("shot on a 35mm anamorphic lens, gentle handheld micro-movement, "
"shallow depth of field")
if not any(c in lower for c in _LIGHT_CUES):
parts.append("natural cinematic lighting with soft volumetric haze and subtle "
"rim light separating subject from background")
if not any(c in lower for c in _MOTION_CUES):
parts.append("smooth continuous motion with grounded physics")
parts.append("highly detailed, coherent temporal continuity, film grain, "
"color graded like a contemporary drama")
enhanced = ", ".join(parts)
# Single trailing period; collapse duplicate whitespace.
enhanced = re.sub(r"\s+", " ", enhanced).strip()
if not enhanced.endswith("."):
enhanced += "."
return enhanced
def enhance_prompt_mlx_lm(
prompt: str,
*,
model: str = DEFAULT_MLX_LM_MODEL,
system_prompt: str = DEFAULT_ENHANCE_SYSTEM_PROMPT,
max_tokens: int = 128,
temp: float = 0.6,
) -> str:
"""
Enhance a user prompt with a locally hosted mlx-lm instruction model.
Parameters:
prompt (str): The prompt to enhance.
model (str): The mlx-lm model identifier or path.
system_prompt (str): Instructions that guide prompt enhancement.
max_tokens (int): Maximum number of tokens to generate.
temp (float): Sampling temperature for generation.
Returns:
str: The enhanced prompt.
Raises:
RuntimeError: If mlx-lm is unavailable or produces an empty result.
"""
try:
from mlx_lm import generate, load
except ImportError as exc: # pragma: no cover - optional dep
raise RuntimeError("mlx-lm is not installed. `uv pip install mlx-lm` or use "
"--enhance-prompt-backend template.") from exc
text = _normalize_user_prompt(prompt)
logger.info("[MLX enhance] loading %s", model)
mlx_model, tokenizer = load(model)
messages = [
{
"role": "system",
"content": system_prompt
},
{
"role": "user",
"content": text
},
]
if hasattr(tokenizer, "apply_chat_template"):
chat = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
else: # pragma: no cover - ancient tokenizers
chat = f"{system_prompt}\n\nUser: {text}\nAssistant:"
raw = generate(
mlx_model,
tokenizer,
prompt=chat,
max_tokens=max_tokens,
temp=temp,
verbose=False,
)
enhanced = _clean_llm_output(raw, original=text)
if not enhanced:
raise RuntimeError("mlx-lm returned an empty enhance result")
return enhanced
def _clean_llm_output(raw: str, *, original: str) -> str:
"""
Clean generated prompt text and fall back to the original when the result is too short.
Parameters:
raw (str): Raw text produced by the language model.
original (str): Original prompt used as the fallback value.
Returns:
str: Cleaned first paragraph of the generated text, or the original prompt when the generated text is too short.
"""
text = raw.strip()
# Drop common prefatory phrases.
for prefix in (
"enhanced prompt:",
"here's the enhanced prompt:",
"here is the enhanced prompt:",
"sure:",
"sure,",
):
if text.lower().startswith(prefix):
text = text[len(prefix):].strip()
# Keep first non-empty paragraph only.
para = text.split("\n\n")[0].strip()
para = " ".join(para.split())
if len(para) < max(12, len(original) // 4):
return original
return para
def enhance_prompt(
prompt: str,
*,
backend: str = "auto",
model: str | None = None,
system_prompt: str = DEFAULT_ENHANCE_SYSTEM_PROMPT,
max_tokens: int = 128,
) -> EnhanceResult:
"""Enhance a prompt using the selected backend, falling back to a deterministic template when configured for automatic selection.
Parameters:
prompt (str): The prompt to enhance.
backend (str): The enhancement backend: ``"auto"``, ``"mlx-lm"``, or ``"template"``.
model (str | None): The MLX language model to use.
system_prompt (str): Instructions provided to the MLX language model.
max_tokens (int): Maximum number of tokens generated by the MLX language model.
Returns:
EnhanceResult: The original and enhanced prompts, selected backend, timing information, and model metadata.
Raises:
ValueError: If the prompt is empty or the backend is unsupported.
Exception: If the explicitly selected ``"mlx-lm"`` backend fails.
"""
text = _normalize_user_prompt(prompt)
backend_norm = (backend or "auto").lower()
if backend_norm not in {"auto", "mlx-lm", "template"}:
raise ValueError(f"Unknown enhance backend: {backend}")
start = time.perf_counter()
used_model: str | None = None
if backend_norm in {"auto", "mlx-lm"}:
try:
used_model = model or DEFAULT_MLX_LM_MODEL
enhanced = enhance_prompt_mlx_lm(
text,
model=used_model,
system_prompt=system_prompt,
max_tokens=max_tokens,
)
return EnhanceResult(
original=text,
enhanced=enhanced,
backend="mlx-lm",
elapsed_s=time.perf_counter() - start,
model=used_model,
)
except Exception as exc:
if backend_norm == "mlx-lm":
raise
logger.info(
"[MLX enhance] mlx-lm unavailable (%s); using template backend",
exc,
)
enhanced = enhance_prompt_template(text)
return EnhanceResult(
original=text,
enhanced=enhanced,
backend="template",
elapsed_s=time.perf_counter() - start,
model=None,
)
def enhance_cache_path(
prompt: str,
*,
backend: str,
model: str | None,
cache_dir: Path | None = None,
) -> Path:
"""Content-addressed cache file for an enhanced prompt string."""
root = cache_dir or (Path.home() / ".cache" / "fastvideo" / "enhanced_prompts")
key = "\0".join([prompt, backend, model or ""])
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:24]
return root / f"{digest}.json"
def load_or_enhance_prompt(
prompt: str,
*,
backend: str = "auto",
model: str | None = None,
system_prompt: str = DEFAULT_ENHANCE_SYSTEM_PROMPT,
max_tokens: int = 128,
cache: bool = True,
cache_dir: Path | None = None,
) -> EnhanceResult:
"""
Enhance a prompt, reusing a cached result when available.
Parameters:
prompt (str): The prompt to enhance.
backend (str): Enhancement backend to use.
model (str | None): Optional model identifier.
system_prompt (str): System prompt for model-based enhancement.
max_tokens (int): Maximum number of tokens generated by the model.
cache (bool): Whether to read and write the on-disk cache.
cache_dir (Path | None): Optional directory for cached results.
Returns:
EnhanceResult: The enhanced prompt and backend metadata. Cached results are marked with the ``"cache"`` backend.
"""
text = _normalize_user_prompt(prompt)
path = enhance_cache_path(text, backend=backend, model=model, cache_dir=cache_dir)
if cache and path.is_file():
try:
payload = json.loads(path.read_text())
return EnhanceResult(
original=str(payload.get("original", text)),
enhanced=str(payload["enhanced"]),
# Mark cache hits explicitly so metrics/logs can distinguish
# a free replay from a fresh template/mlx-lm call.
backend="cache",
elapsed_s=0.0,
model=payload.get("model"),
)
except (OSError, KeyError, json.JSONDecodeError):
pass
result = enhance_prompt(
text,
backend=backend,
model=model,
system_prompt=system_prompt,
max_tokens=max_tokens,
)
if cache:
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"original": result.original,
"enhanced": result.enhanced,
"backend": result.backend,
"model": result.model,
},
indent=2,
))
except OSError as exc: # pragma: no cover - cache is best-effort
logger.info("[MLX enhance] cache write skipped: %s", exc)
return result
def enhance_result_as_metrics(result: EnhanceResult | None) -> dict[str, Any]:
"""
Convert prompt enhancement results into metrics fields.
Parameters:
result (EnhanceResult | None): The enhancement result, or `None` when no enhancement was performed.
Returns:
dict[str, Any]: A metrics mapping containing enhancement status, backend metadata, timing, and original and enhanced prompts.
"""
if result is None:
return {
"enhance_prompt": False,
"enhance_backend": None,
"enhance_model": None,
"enhance_elapsed_s": None,
"prompt_original": None,
"prompt_enhanced": None,
}
return {
"enhance_prompt": True,
"enhance_backend": result.backend,
"enhance_model": result.model,
"enhance_elapsed_s": result.elapsed_s,
"prompt_original": result.original,
"prompt_enhanced": result.enhanced,
}
__all__ = [
"DEFAULT_ENHANCE_SYSTEM_PROMPT",
"DEFAULT_MLX_LM_MODEL",
"EnhanceResult",
"enhance_cache_path",
"enhance_prompt",
"enhance_prompt_mlx_lm",
"enhance_prompt_template",
"enhance_result_as_metrics",
"load_or_enhance_prompt",
]
+284
View File
@@ -0,0 +1,284 @@
# SPDX-License-Identifier: Apache-2.0
"""MLX block-scaled quantization backends (affine INT8, MXFP8/4, NVFP4).
Isolated experiment module: probes which ``mx.quantize`` modes the installed
MLX build supports and exposes a thin wrapper around native quantized matmul.
Depends only on ``mlx.core`` and the standard library — do not import the rest
of FastVideo from here.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Final
from collections.abc import Mapping
import mlx.core as mx
# Probe matrix side length: must be divisible by every mode's group size
# (affine g64, mxfp* g32, nvfp4 g16).
_PROBE_DIM: Final[int] = 64
class QuantBackend(str, Enum):
"""Named MLX quantization backends evaluated for M5 Neural Accelerators."""
AFFINE_INT8_G64 = "affine_int8_g64"
MXFP8 = "mxfp8"
MXFP4 = "mxfp4"
NVFP4 = "nvfp4"
BACKENDS: Final[tuple[str, ...]] = tuple(b.value for b in QuantBackend)
# Backend name -> kwargs for mx.quantize / mx.quantized_matmul.
# Affine baseline matches FastVideo DiT load path (INT8, group size 64).
# MX/NV block-scaled modes use MLX defaults (see mx.quantize docs).
_BACKEND_KWARGS: Final[Mapping[str, Mapping[str, object]]] = {
QuantBackend.AFFINE_INT8_G64.value: {
"mode": "affine",
"bits": 8,
"group_size": 64,
},
QuantBackend.MXFP8.value: {
"mode": "mxfp8",
"bits": None,
"group_size": None,
},
QuantBackend.MXFP4.value: {
"mode": "mxfp4",
"bits": None,
"group_size": None,
},
QuantBackend.NVFP4.value: {
"mode": "nvfp4",
"bits": None,
"group_size": None,
},
}
_SUPPORT_CACHE: dict[str, bool] = {}
_SUPPORT_ERROR_CACHE: dict[str, str | None] = {}
_BYTES_CACHE: dict[str, float] = {}
@dataclass(frozen=True)
class QuantizedWeight:
"""Packed quantized weight plus scales/biases for one backend."""
weight: mx.array
scales: mx.array
biases: mx.array | None
backend: str
mode: str
bits: int | None
group_size: int | None
# Original (rows, cols) of the fp weight, used for bytes-per-element.
orig_shape: tuple[int, int]
def _normalize_backend(backend: str) -> str:
"""Normalize a quantization backend name and validate that it is supported.
Parameters:
backend (str): Backend name to normalize.
Returns:
str: The lowercase backend name without surrounding whitespace.
Raises:
ValueError: If the backend name is unknown.
"""
name = backend.strip().lower()
if name not in _BACKEND_KWARGS:
known = ", ".join(BACKENDS)
raise ValueError(f"Unknown quant backend {backend!r}. Expected one of: {known}")
return name
def _kwargs_for(backend: str) -> dict[str, object]:
"""Return the MLX quantization arguments configured for a backend.
Parameters:
backend (str): Backend name to resolve.
Returns:
dict[str, object]: Quantization arguments for the normalized backend.
"""
return dict(_BACKEND_KWARGS[_normalize_backend(backend)])
def support_error(backend: str) -> str | None:
"""
Check whether a quantization backend is supported by the current MLX runtime.
Parameters:
backend (str): Quantization backend name.
Returns:
str | None: An error description when the backend is unsupported, or `None` when supported.
"""
name = _normalize_backend(backend)
if name in _SUPPORT_ERROR_CACHE:
return _SUPPORT_ERROR_CACHE[name]
kwargs = _kwargs_for(name)
try:
w = mx.zeros((_PROBE_DIM, _PROBE_DIM), dtype=mx.float16)
quantized = mx.quantize(
w,
group_size=kwargs["group_size"], # type: ignore[arg-type]
bits=kwargs["bits"], # type: ignore[arg-type]
mode=str(kwargs["mode"]),
)
w_q = quantized[0]
scales = quantized[1]
biases = quantized[2] if len(quantized) == 3 else None
x = mx.zeros((1, _PROBE_DIM), dtype=mx.float16)
y = mx.quantized_matmul(
x,
w_q,
scales,
biases,
transpose=True,
group_size=kwargs["group_size"], # type: ignore[arg-type]
bits=kwargs["bits"], # type: ignore[arg-type]
mode=str(kwargs["mode"]),
)
mx.eval(y)
_SUPPORT_ERROR_CACHE[name] = None
_SUPPORT_CACHE[name] = True
except Exception as exc: # noqa: BLE001 - MLX raises varied types per mode/version.
msg = f"{type(exc).__name__}: {exc}"
_SUPPORT_ERROR_CACHE[name] = msg
_SUPPORT_CACHE[name] = False
return _SUPPORT_ERROR_CACHE[name]
def is_supported(backend: str) -> bool:
"""Return True if the installed MLX build can quantize/matmul with ``backend``."""
name = _normalize_backend(backend)
if name not in _SUPPORT_CACHE:
support_error(name)
return _SUPPORT_CACHE[name]
def quantize_weight(w: mx.array, backend: str) -> QuantizedWeight:
"""
Quantize a two-dimensional weight matrix using the specified native MLX backend.
Parameters:
w (mx.array): The two-dimensional weight matrix to quantize.
backend (str): The quantization backend to use.
Returns:
QuantizedWeight: The quantized weights and associated quantization metadata.
Raises:
ValueError: If the backend is unknown, the weight is not two-dimensional,
or its last dimension is not divisible by the backend's group size.
RuntimeError: If the backend is unsupported by the installed MLX build.
"""
name = _normalize_backend(backend)
err = support_error(name)
if err is not None:
mlx_version = getattr(mx, "__version__", "unknown")
raise RuntimeError(f"Quant backend {name!r} is not supported by installed mlx "
f"({mlx_version}): {err}")
if w.ndim != 2:
raise ValueError(f"quantize_weight expects a 2D weight, got shape {tuple(w.shape)}")
rows, cols = int(w.shape[0]), int(w.shape[1])
kwargs = _kwargs_for(name)
group_size = kwargs["group_size"]
# When group_size is None, MLX applies the mode default; only check when set.
if isinstance(group_size, int) and cols % group_size != 0:
raise ValueError(f"Weight last dim {cols} must be divisible by group_size={group_size} "
f"for backend {name!r}")
quantized = mx.quantize(
w,
group_size=kwargs["group_size"], # type: ignore[arg-type]
bits=kwargs["bits"], # type: ignore[arg-type]
mode=str(kwargs["mode"]),
)
w_q = quantized[0]
scales = quantized[1]
biases = quantized[2] if len(quantized) == 3 else None
eval_args = [w_q, scales] if biases is None else [w_q, scales, biases]
mx.eval(*eval_args)
return QuantizedWeight(
weight=w_q,
scales=scales,
biases=biases,
backend=name,
mode=str(kwargs["mode"]),
bits=kwargs["bits"] if isinstance(kwargs["bits"], int) else None,
group_size=group_size if isinstance(group_size, int) else None,
orig_shape=(rows, cols),
)
def quantized_matmul(x: mx.array, qw: QuantizedWeight) -> mx.array:
"""Compute ``x @ w.T`` in the quantized domain via ``mx.quantized_matmul``."""
return mx.quantized_matmul(
x,
qw.weight,
qw.scales,
qw.biases,
transpose=True,
group_size=qw.group_size,
bits=qw.bits,
mode=qw.mode,
)
def _artifact_nbytes(qw: QuantizedWeight) -> int:
"""
Calculate the total storage size of a quantized weight artifact in bytes.
Parameters:
qw (QuantizedWeight): Quantized weight artifact whose packed weights, scales, and optional biases are measured.
Returns:
int: Total number of bytes used by the artifact's stored arrays.
"""
total = int(qw.weight.nbytes) + int(qw.scales.nbytes)
if qw.biases is not None:
total += int(qw.biases.nbytes)
return total
def bytes_per_weight(backend: str) -> float:
"""
Measure the effective storage cost of a quantized weight.
Parameters:
backend (str): Quantization backend to measure.
Returns:
float: Stored bytes per original weight element, including packed weights,
scales, and optional biases.
Raises:
RuntimeError: If the backend is unsupported.
"""
name = _normalize_backend(backend)
if name in _BYTES_CACHE:
return _BYTES_CACHE[name]
err = support_error(name)
if err is not None:
mlx_version = getattr(mx, "__version__", "unknown")
raise RuntimeError(f"Cannot measure bytes_per_weight for unsupported backend {name!r} "
f"(mlx {mlx_version}): {err}")
probe = mx.zeros((_PROBE_DIM, _PROBE_DIM), dtype=mx.float16)
qw = quantize_weight(probe, name)
n_elem = qw.orig_shape[0] * qw.orig_shape[1]
value = _artifact_nbytes(qw) / float(n_elem)
_BYTES_CACHE[name] = value
return value
+690
View File
@@ -0,0 +1,690 @@
# SPDX-License-Identifier: Apache-2.0
"""Two-pass spatial refine for the MLX Wan runtime (H3 / LTX-2 pattern).
Biggest quality lever on Apple Silicon without a new model or training:
generate at base resolution, then run a second denoising pass with the
*same* DiT at a higher resolution.
This is the MLX-side port of the CUDA refine template in
``fastvideo/pipelines/basic/ltx2/stages/ltx2_refine.py`` and the H3
"base + regenerate" pattern documented in
``docs/design/mac_qad_two_product_strategy.md``:
1. :func:`plan_refine_resolutions` — split the request into stage-1
(base) and stage-2 (target) pixel sizes, validating VAE / patch
alignment the way :class:`LTX2RefineInitStage` does.
2. :func:`upsample_latents_spatial` — 2× (or N×) spatial upsample of
clean latents. Wan has no learned latent upsampler on Mac, so this
is bilinear over the H×W plane (temporal axis untouched) — same
role as LTX-2's ``upsample_video`` hand-off, without the learned
residual.
3. :func:`prepare_refine_latents` — upsample + re-noise the clean
stage-1 latents to the stage-2 sigma so the second denoise has
something to refine (mirrors :class:`LTX2UpsampleStage` +
``apply_ltx2_gaussian_noiser``).
4. :func:`run_two_pass_dmd` — orchestrate stage-1 denoise → refine
hand-off → stage-2 denoise with the same model / prompt embeds.
No LoRA swap, no dedicated SR weights, no new training — pure pipeline
work reusable by Wan2.1-14B and Wan2.2-5B on Apple Silicon.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from collections.abc import Callable, Sequence
import numpy as np
from fastvideo.logger import init_logger
from fastvideo.mlx_runtime.sampling import MLXDMDSchedule, add_noise, dmd_step
if TYPE_CHECKING: # pragma: no cover - typing only
import mlx.core as mx
logger = init_logger(__name__)
# Default stage-2 noise level when the caller does not supply a schedule.
# Matches the first entry of LTX-2's STAGE_2_DISTILLED_SIGMA_VALUES in spirit
# (start the refine denoise from a high-noise level) without hard-wiring the
# LTX-2 distilled grid onto Wan's flow-match schedule.
DEFAULT_REFINE_SIGMA = 0.909375
@dataclass(frozen=True)
class RefinePlan:
"""Resolved stage-1 / stage-2 geometry for a two-pass refine run."""
target_height: int
target_width: int
stage1_height: int
stage1_width: int
spatial_scale: int
vae_spatial_compression: int
vae_temporal_compression: int
num_frames: int
@property
def stage1_latent_height(self) -> int:
"""Return the stage-1 latent height after VAE spatial compression."""
return self.stage1_height // self.vae_spatial_compression
@property
def stage1_latent_width(self) -> int:
"""Return the stage-one latent width after VAE spatial compression."""
return self.stage1_width // self.vae_spatial_compression
@property
def stage2_latent_height(self) -> int:
"""Calculate the target-resolution latent height.
Returns:
int: The target height divided by the VAE spatial compression factor.
"""
return self.target_height // self.vae_spatial_compression
@property
def stage2_latent_width(self) -> int:
"""Return the target image width in latent-space units."""
return self.target_width // self.vae_spatial_compression
@property
def latent_frames(self) -> int:
"""Calculate the number of latent frames after VAE temporal compression.
Returns:
int: The compressed latent frame count.
"""
return (self.num_frames - 1) // self.vae_temporal_compression + 1
def plan_refine_resolutions(
*,
height: int,
width: int,
num_frames: int,
spatial_scale: int = 2,
vae_spatial_compression: int = 8,
vae_temporal_compression: int = 4,
patch_size: tuple[int, int, int] = (1, 2, 2),
enabled: bool = True,
mode_label: str = "Refine",
) -> RefinePlan:
"""
Validate the requested dimensions and create the stage-1 and target-resolution refinement plan.
Parameters:
height (int): Target image height in pixels.
width (int): Target image width in pixels.
num_frames (int): Number of frames in the input sequence.
spatial_scale (int): Factor used to reduce spatial dimensions for stage 1.
vae_spatial_compression (int): Spatial compression factor of the VAE.
vae_temporal_compression (int): Temporal compression factor of the VAE.
patch_size (tuple[int, int, int]): Temporal and spatial patch dimensions used to validate latent-grid alignment.
enabled (bool): Whether to use two-pass refinement.
mode_label (str): Name of the calling mode, used to prefix validation
errors so ``--fast-spatial`` failures do not read as refine failures.
Returns:
RefinePlan: The validated stage-1 and target-resolution plan.
"""
if height <= 0 or width <= 0:
raise ValueError(f"height/width must be positive, got {height}x{width}")
if spatial_scale < 1:
raise ValueError(f"spatial_scale must be >= 1, got {spatial_scale}")
if num_frames <= 0:
raise ValueError(f"num_frames must be positive, got {num_frames}")
if vae_spatial_compression < 1 or vae_temporal_compression < 1:
raise ValueError("VAE compression factors must be positive")
if height % vae_spatial_compression != 0 or width % vae_spatial_compression != 0:
raise ValueError(f"height/width must be divisible by vae_spatial_compression={vae_spatial_compression} "
f"(got {height}x{width}).")
if (num_frames - 1) % vae_temporal_compression != 0:
raise ValueError(f"num_frames must be 1 modulo vae_temporal_compression={vae_temporal_compression} "
f"(got {num_frames}).")
if not enabled or spatial_scale == 1:
plan = RefinePlan(
target_height=height,
target_width=width,
stage1_height=height,
stage1_width=width,
spatial_scale=1,
vae_spatial_compression=vae_spatial_compression,
vae_temporal_compression=vae_temporal_compression,
num_frames=num_frames,
)
_validate_plan(plan, patch_size=patch_size, mode_label=mode_label)
return plan
if height % spatial_scale != 0 or width % spatial_scale != 0:
raise ValueError(f"{mode_label} requires height/width divisible by spatial_scale={spatial_scale} "
f"(got {height}x{width}).")
stage1_height = height // spatial_scale
stage1_width = width // spatial_scale
# Stage-1 must land on a VAE-aligned grid so the first denoise produces
# valid latents; the LTX-2 init stage enforces the same constraint.
if (stage1_height % vae_spatial_compression != 0 or stage1_width % vae_spatial_compression != 0):
raise ValueError(f"{mode_label} requires height/width divisible by "
f"{spatial_scale * vae_spatial_compression} "
f"(got {height}x{width}, vae_spatial={vae_spatial_compression}).")
plan = RefinePlan(
target_height=height,
target_width=width,
stage1_height=stage1_height,
stage1_width=stage1_width,
spatial_scale=spatial_scale,
vae_spatial_compression=vae_spatial_compression,
vae_temporal_compression=vae_temporal_compression,
num_frames=num_frames,
)
_validate_plan(plan, patch_size=patch_size, mode_label=mode_label)
logger.info(
"[MLX refine] enabled: stage1=%dx%d stage2=%dx%d scale=%dx",
stage1_width,
stage1_height,
width,
height,
spatial_scale,
)
return plan
def _validate_plan(plan: RefinePlan, *, patch_size: tuple[int, int, int], mode_label: str = "Refine") -> None:
"""
Validate that both refinement stages have latent dimensions aligned to the patch grid.
Parameters:
patch_size (tuple[int, int, int]): Temporal, height, and width patch dimensions.
Raises:
ValueError: If a stage's spatial latent dimensions or the temporal latent
dimension is not divisible by the corresponding patch dimension.
"""
pt, ph, pw = patch_size
for label, lh, lw in (
("stage1", plan.stage1_latent_height, plan.stage1_latent_width),
("stage2", plan.stage2_latent_height, plan.stage2_latent_width),
):
if lh % ph != 0 or lw % pw != 0:
raise ValueError(f"{mode_label} {label} latent grid {lh}x{lw} is not divisible by "
f"patch spatial size {ph}x{pw}.")
if plan.latent_frames % pt != 0:
raise ValueError(f"{mode_label} latent_frames={plan.latent_frames} is not divisible by "
f"patch temporal size {pt}.")
def upsample_latents_spatial(
latents: Any,
*,
scale: int = 2,
mode: str = "bilinear",
) -> Any:
"""
Upsample the spatial dimensions of 5-D latent arrays while preserving the batch, channel, and temporal dimensions.
Parameters:
latents (Any): Latents with shape ``(B, C, T, H, W)``.
scale (int): Integer factor for enlarging the spatial dimensions.
mode (str): Interpolation mode, either ``"nearest"`` or ``"bilinear"``.
Returns:
Any: Latents with shape ``(B, C, T, H * scale, W * scale)``.
"""
if scale < 1:
raise ValueError(f"scale must be >= 1, got {scale}")
if scale == 1:
return latents
# Accept both mx.array and np.ndarray so unit tests can run without MLX.
is_mlx = hasattr(latents, "dtype") and type(latents).__module__.startswith("mlx")
if is_mlx:
return _upsample_latents_mlx(latents, scale=scale, mode=mode)
return _upsample_latents_numpy(np.asarray(latents), scale=scale, mode=mode)
def _upsample_latents_numpy(
latents: np.ndarray,
*,
scale: int,
mode: str,
) -> np.ndarray:
"""Upsample 5-D latent arrays spatially using nearest-neighbor or bilinear interpolation.
Parameters:
latents (np.ndarray): Latents with shape ``(B, C, T, H, W)``.
scale (int): Spatial upsampling factor.
mode (str): Interpolation mode, either ``"nearest"`` or ``"bilinear"``.
Returns:
np.ndarray: Spatially upsampled latents with preserved batch, channel, and temporal dimensions.
Raises:
ValueError: If the latents are not five-dimensional or the interpolation mode is unsupported.
"""
if latents.ndim != 5:
raise ValueError(f"Expected 5-D latents (B,C,T,H,W), got shape {latents.shape}")
b, c, t, h, w = latents.shape
if mode == "nearest":
# (B,C,T,H,1,W,1) -> broadcast to (B,C,T,H,scale,W,scale) -> merge.
out = np.repeat(np.repeat(latents, scale, axis=3), scale, axis=4)
return out
if mode != "bilinear":
raise ValueError(f"Unsupported upsample mode: {mode}")
# Bilinear over the spatial plane. Flatten (B,C,T) into a batch of 2-D
# maps so a single vectorized gather covers every frame/channel.
src = latents.reshape(b * c * t, h, w).astype(np.float32, copy=False)
out_h, out_w = h * scale, w * scale
# Map output pixel centers onto the input grid (align_corners=False).
ys = (np.arange(out_h, dtype=np.float32) + 0.5) * (h / out_h) - 0.5
xs = (np.arange(out_w, dtype=np.float32) + 0.5) * (w / out_w) - 0.5
ys = np.clip(ys, 0.0, h - 1.0)
xs = np.clip(xs, 0.0, w - 1.0)
y0 = np.floor(ys).astype(np.int64)
x0 = np.floor(xs).astype(np.int64)
y1 = np.minimum(y0 + 1, h - 1)
x1 = np.minimum(x0 + 1, w - 1)
wy = (ys - y0.astype(np.float32))[:, None]
wx = (xs - x0.astype(np.float32))[None, :]
# Gather the four corners: shape (N, out_h, out_w).
Ia = src[:, y0[:, None], x0[None, :]]
Ib = src[:, y0[:, None], x1[None, :]]
Ic = src[:, y1[:, None], x0[None, :]]
Id = src[:, y1[:, None], x1[None, :]]
wa = (1.0 - wy) * (1.0 - wx)
wb = (1.0 - wy) * wx
wc = wy * (1.0 - wx)
wd = wy * wx
out = wa * Ia + wb * Ib + wc * Ic + wd * Id
return out.reshape(b, c, t, out_h, out_w).astype(latents.dtype, copy=False)
def _upsample_latents_mlx(latents: mx.array, *, scale: int, mode: str) -> mx.array:
"""
Upsample MLX latent tensors along their spatial dimensions.
Parameters:
latents (mx.array): A latent tensor with shape `(B, C, T, H, W)`.
scale (int): The integer spatial upsampling factor.
mode (str): The interpolation mode, such as `"nearest"` or `"bilinear"`.
Returns:
mx.array: The spatially upsampled latent tensor with its original data type.
"""
import mlx.core as mx
# Route through NumPy for the interpolation math. Latent tensors at Mac
# resolutions are small (e.g. 1×16×21×30×52 ≈ 1 MB) so the host hop is
# cheaper than carrying a bespoke Metal bilinear kernel, and it keeps
# the CPU-only unit tests and the MLX path on one implementation.
np_latents = np.array(latents.astype(mx.float32))
up = _upsample_latents_numpy(np_latents, scale=scale, mode=mode)
return mx.array(up).astype(latents.dtype)
def prepare_refine_latents(
clean_latents: Any,
*,
scale: int = 2,
sigma: float = DEFAULT_REFINE_SIGMA,
noise: Any | None = None,
add_noise_flag: bool = True,
upsample_mode: str = "bilinear",
seed: int | None = None,
) -> Any:
"""
Upsample clean latents spatially and optionally mix them with Gaussian noise.
Parameters:
clean_latents: The stage-1 latent tensor.
sigma: Noise mixing factor between 0 and 1.
noise: Optional noise tensor to mix with the upsampled latents.
add_noise_flag: Whether to apply noise mixing.
upsample_mode: Spatial interpolation mode.
seed: Optional seed for generated noise.
Returns:
The upsampled latents, optionally mixed with noise.
Raises:
ValueError: If sigma is outside the range from 0 to 1.
"""
if sigma < 0.0 or sigma > 1.0:
raise ValueError(f"sigma must be in [0, 1], got {sigma}")
upsampled = upsample_latents_spatial(clean_latents, scale=scale, mode=upsample_mode)
if not add_noise_flag or sigma == 0.0:
return upsampled
is_mlx = hasattr(upsampled, "dtype") and type(upsampled).__module__.startswith("mlx")
if noise is None:
noise = _draw_noise_like(upsampled, seed=seed, is_mlx=is_mlx)
return add_noise(upsampled, noise, float(sigma))
def refine_sigma_from_schedule(
schedule: MLXDMDSchedule,
timesteps: Sequence[float | int],
) -> float:
"""Derive the refinement noise level from the first refinement timestep.
Parameters:
schedule (MLXDMDSchedule): Schedule used to map timesteps to noise levels.
timesteps (Sequence[float | int]): Refinement timesteps, whose first value determines the sigma.
Returns:
float: Sigma corresponding to the first refinement timestep.
Raises:
ValueError: If `timesteps` is empty.
"""
if not timesteps:
raise ValueError("timesteps must be non-empty to derive a refine sigma")
return float(schedule.sigma_for(float(timesteps[0])))
def default_refine_timesteps(
schedule: MLXDMDSchedule,
timesteps: Sequence[float | int],
) -> list[float]:
"""Derive stage-2 timesteps from the stage-1 DMD grid.
The stage-2 pass must start *below* full noise, otherwise the hand-off
``(1 - sigma) * upsampled + sigma * noise`` weights stage 1 at zero and
the refine pass silently becomes a plain full-resolution generation at
twice the cost. FastWan's stage-1 grid opens at ``t=1000`` (``sigma``
exactly 1.0), so reusing it verbatim — which is what happens when
``--refine-dmd-denoising-steps`` is left unset — discards stage 1.
Dropping the leading full-noise entries keeps the pass on timesteps the
distilled student was actually trained on (no off-grid ``t`` the DiT has
never seen) while letting the stage-1 structure through.
Parameters:
schedule (MLXDMDSchedule): Schedule used to map timesteps to noise levels.
timesteps (Sequence[float | int]): The stage-1 DMD timestep grid.
Returns:
list[float]: The stage-1 grid with leading full-noise timesteps removed.
Raises:
ValueError: If every timestep in the grid is at full noise, leaving no
usable refine step.
"""
steps = [float(step) for step in timesteps]
first = 0
while first < len(steps) and schedule.sigma_for(steps[first]) >= 1.0:
first += 1
if first == len(steps):
raise ValueError(f"No usable refine timesteps in {steps}: every entry is at sigma >= 1 "
"(full noise), which would discard the stage-1 result. Pass "
"explicit stage-2 timesteps below the full-noise step.")
return steps[first:]
def run_dmd_loop(
*,
dit: Any,
latents: Any,
encoder_hidden_states: Any,
freqs_cis: tuple[Any, Any],
timesteps: Sequence[float | int],
schedule: MLXDMDSchedule,
mx_dtype: Any,
seed: int | None = None,
step_callback: Callable[[int, int], None] | None = None,
label: str = "denoise",
) -> Any:
"""
Denoise latents over the supplied timesteps using the DMD schedule.
Parameters:
timesteps (Sequence[float | int]): Denoising timesteps in execution order.
seed (int | None): Seed for reproducible intermediate noise generation.
step_callback (Callable[[int, int], None] | None): Callback receiving the
completed step number and total step count.
label (str): Label used for progress output when no callback is provided.
Returns:
Any: The denoised latents.
"""
import mlx.core as mx
renoise_rng = np.random.default_rng(seed) if seed is not None else None
latents_out = latents
n_steps = len(timesteps)
for step_index, timestep in enumerate(timesteps):
noise_input = latents_out
ts_val = float(timestep)
timestep_mx = mx.array([ts_val]).astype(mx.float32)
noise_pred = dit(
latents_out.astype(mx_dtype),
encoder_hidden_states,
timestep_mx,
freqs_cis,
)
noise_input_f32 = noise_input.astype(mx.float32)
pred_noise_f32 = noise_pred.astype(mx.float32)
if step_index < n_steps - 1:
next_ts: float | None = float(timesteps[step_index + 1])
if renoise_rng is not None:
renoise = mx.array(renoise_rng.standard_normal(tuple(noise_input_f32.shape)).astype(np.float32))
else:
renoise = mx.random.normal(noise_input_f32.shape).astype(mx.float32)
else:
next_ts, renoise = None, None
latents_out = dmd_step(
latents=noise_input_f32,
noise_input_latent=noise_input_f32,
pred_noise=pred_noise_f32,
schedule=schedule,
timestep=ts_val,
next_timestep=next_ts,
noise=renoise,
).astype(mx_dtype)
mx.eval(latents_out)
if step_callback is not None:
step_callback(step_index + 1, n_steps)
else:
print(f"{label} step {step_index + 1}/{n_steps} complete")
return latents_out
@dataclass(frozen=True)
class TwoPassResult:
"""Outputs of :func:`run_two_pass_dmd`."""
latents: Any
stage1_latents: Any
plan: RefinePlan
refine_sigma: float
def run_two_pass_dmd(
*,
dit: Any,
encoder_hidden_states: Any,
noise_latents_stage1: Any,
freqs_cis_stage1: tuple[Any, Any],
freqs_cis_stage2: tuple[Any, Any] | None,
plan: RefinePlan,
schedule: MLXDMDSchedule,
timesteps: Sequence[float | int],
refine_timesteps: Sequence[float | int] | None = None,
mx_dtype: Any,
seed: int = 0,
add_noise_flag: bool = True,
upsample_mode: str = "bilinear",
refine_sigma: float | None = None,
step_callback: Callable[[str, int, int], None] | None = None,
) -> TwoPassResult:
"""
Run base denoising and, when enabled, spatial refinement denoising.
Parameters:
dit: DiT callable used for both denoising passes.
encoder_hidden_states: Prompt embeddings shared across both passes.
noise_latents_stage1: Initial stage-1 noise latents.
freqs_cis_stage1: RoPE tables for the stage-1 resolution.
freqs_cis_stage2: RoPE tables for the stage-2 resolution, required when refinement is enabled.
plan: Refinement geometry and configuration.
schedule: Flow-matching schedule used by both passes.
timesteps: Stage-1 denoising timesteps.
refine_timesteps: Stage-2 denoising timesteps. Uses `timesteps` when omitted.
mx_dtype: MLX dtype used for DiT inputs and outputs.
seed: Base seed for reproducible noise generation.
add_noise_flag: Whether to add noise to the upsampled stage-1 latents.
upsample_mode: Spatial upsampling mode, either `"bilinear"` or `"nearest"`.
refine_sigma: Stage-2 starting noise level. Derived from the first refinement timestep when omitted.
step_callback: Optional callback receiving the phase name, step index, and total step count.
Returns:
TwoPassResult containing the final latents, stage-1 latents, refinement plan, and applied refinement sigma.
Raises:
ValueError: If refinement is enabled without stage-2 RoPE tables, without refinement timesteps, or if upsampled latents do not match the planned stage-2 dimensions.
"""
stage1_cb = None
stage2_cb = None
if step_callback is not None:
stage1_cb = lambda i, n: step_callback("stage1", i, n) # noqa: E731
stage2_cb = lambda i, n: step_callback("stage2", i, n) # noqa: E731
stage1_latents = run_dmd_loop(
dit=dit,
latents=noise_latents_stage1,
encoder_hidden_states=encoder_hidden_states,
freqs_cis=freqs_cis_stage1,
timesteps=timesteps,
schedule=schedule,
mx_dtype=mx_dtype,
seed=seed,
step_callback=stage1_cb,
label="stage1 denoise",
)
if plan.spatial_scale == 1:
return TwoPassResult(
latents=stage1_latents,
stage1_latents=stage1_latents,
plan=plan,
refine_sigma=0.0,
)
if freqs_cis_stage2 is None:
raise ValueError("freqs_cis_stage2 is required when refine spatial_scale > 1")
if refine_timesteps is not None:
stage2_timesteps = [float(step) for step in refine_timesteps]
if not stage2_timesteps:
raise ValueError("refine_timesteps must be non-empty when refine is enabled")
else:
# Not `list(timesteps)`: the stage-1 grid opens at full noise, which
# would weight the stage-1 result at zero. See default_refine_timesteps.
stage2_timesteps = default_refine_timesteps(schedule, timesteps)
grid_sigma = refine_sigma_from_schedule(schedule, stage2_timesteps)
sigma = float(refine_sigma) if refine_sigma is not None else grid_sigma
if refine_sigma is not None and abs(sigma - grid_sigma) > 1e-6:
# The loop tells the DiT `stage2_timesteps[0]`, which implies grid_sigma.
# Overriding the hand-off noise level breaks that correspondence, so the
# model is denoising from a level it was not told about. Useful for
# exploring schedules that bottom out too high, but say so out loud.
logger.warning(
"[MLX refine] refine_sigma=%.4f overrides the schedule's %.4f for timestep %g; "
"the DiT is told a timestep that no longer matches the noise it receives.",
sigma,
grid_sigma,
stage2_timesteps[0],
)
# A hand-off at sigma >= 1 is `0 * upsampled + 1 * noise`: stage 1 is
# thrown away and refine degrades to a plain full-res run at 2x the cost.
# Fail loudly rather than silently burning the first pass.
if add_noise_flag and sigma >= 1.0:
raise ValueError(f"Refine hand-off sigma={sigma:.4f} (from stage-2 timestep "
f"{stage2_timesteps[0]:g}) discards the stage-1 result entirely: "
"the upsampled latents are weighted (1 - sigma) = 0. Start the "
"stage-2 grid below the full-noise timestep, or pass "
"add_noise_flag=False to hand off the clean upsample.")
stage2_input = prepare_refine_latents(
stage1_latents,
scale=plan.spatial_scale,
sigma=sigma,
add_noise_flag=add_noise_flag,
upsample_mode=upsample_mode,
seed=seed + 1,
)
# Shape guard: upsampled latents must match the stage-2 RoPE grid.
expected_h = plan.stage2_latent_height
expected_w = plan.stage2_latent_width
got_h, got_w = int(stage2_input.shape[-2]), int(stage2_input.shape[-1])
if got_h != expected_h or got_w != expected_w:
raise ValueError(f"Refine upsample produced {got_h}x{got_w} latents, expected "
f"{expected_h}x{expected_w} for target "
f"{plan.target_height}x{plan.target_width}.")
logger.info(
"[MLX refine] stage2 start: latent=%dx%d sigma=%.4f steps=%d",
expected_w,
expected_h,
sigma,
len(stage2_timesteps),
)
stage2_latents = run_dmd_loop(
dit=dit,
latents=stage2_input,
encoder_hidden_states=encoder_hidden_states,
freqs_cis=freqs_cis_stage2,
timesteps=stage2_timesteps,
schedule=schedule,
mx_dtype=mx_dtype,
seed=seed + 2,
step_callback=stage2_cb,
label="stage2 refine",
)
return TwoPassResult(
latents=stage2_latents,
stage1_latents=stage1_latents,
plan=plan,
refine_sigma=sigma,
)
__all__ = [
"DEFAULT_REFINE_SIGMA",
"RefinePlan",
"TwoPassResult",
"default_refine_timesteps",
"plan_refine_resolutions",
"prepare_refine_latents",
"refine_sigma_from_schedule",
"run_dmd_loop",
"run_two_pass_dmd",
"upsample_latents_spatial",
]
def _draw_noise_like(like: Any, *, seed: int | None, is_mlx: bool) -> Any:
"""Generate Gaussian noise with the shape and array type of the input."""
shape = tuple(int(s) for s in like.shape)
if seed is not None:
rng = np.random.default_rng(seed)
noise_np = rng.standard_normal(shape).astype(np.float32)
if is_mlx:
import mlx.core as mx
return mx.array(noise_np).astype(mx.float32)
return noise_np.astype(np.asarray(like).dtype, copy=False)
if is_mlx:
import mlx.core as mx
return mx.random.normal(shape).astype(mx.float32)
return np.random.standard_normal(shape).astype(np.asarray(like).dtype, copy=False)
+137
View File
@@ -0,0 +1,137 @@
# SPDX-License-Identifier: Apache-2.0
"""Small MLX RIFE wrapper for frame interpolation experiments.
The backend is the Apple-Silicon-native ``rife-mlx`` package, using the
``mlx-community/RIFE-4.25`` weights. Frames are HWC RGB ``uint8`` arrays.
"""
from __future__ import annotations
from collections.abc import Iterable
from functools import lru_cache
import numpy as np
from huggingface_hub.utils import LocalEntryNotFoundError
class RIFEBackendError(RuntimeError):
"""Raised when the MLX RIFE backend cannot be loaded or run."""
class RIFEWeightsUnavailableError(RIFEBackendError):
"""Raised when uncached RIFE weights cannot be downloaded."""
def aligned_keyframe_count(target_frames: int, factor: int, temporal_compression: int = 4) -> int:
"""Return the smallest VAE-aligned keyframe count that RIFE can expand to the target."""
if target_frames < 1:
raise ValueError(f"target_frames must be >= 1, got {target_frames}")
if factor < 1:
raise ValueError(f"factor must be >= 1, got {factor}")
if temporal_compression < 1:
raise ValueError(f"temporal_compression must be >= 1, got {temporal_compression}")
required_intervals = (target_frames - 1 + factor - 1) // factor
aligned_intervals = ((required_intervals + temporal_compression - 1) // temporal_compression * temporal_compression)
return aligned_intervals + 1
def _require_hwc_rgb(frame: np.ndarray, index: int) -> np.ndarray:
array = np.asarray(frame)
if array.ndim != 3 or array.shape[2] != 3:
raise ValueError(f"frame {index} must have shape HxWx3, got {array.shape}")
if array.dtype != np.uint8:
array = np.clip(array, 0, 255).astype(np.uint8)
return np.ascontiguousarray(array)
@lru_cache(maxsize=2)
def load_model(version: str = "4.25", weights_dir: str | None = None):
"""Load the MLX-native RIFE model.
``weights_dir`` is passed through to ``build_model`` in the vendored ``rife_mlx``.
When it is ``None``, the package downloads/uses the Hugging Face
``mlx-community/RIFE-4.25`` snapshot.
"""
try:
from fastvideo.third_party.rife_mlx.utils.weights import build_model
except ImportError:
# Fall back to a separately installed upstream package, for anyone who
# already has one in the environment.
try:
from rife_mlx.utils.weights import build_model
except ImportError as exc:
raise RIFEBackendError("MLX RIFE backend is unavailable. It ships vendored under "
"fastvideo/third_party/rife_mlx, so this usually means MLX "
"itself is missing: install with `uv pip install -e '.[mlx]'`.") from exc
try:
return build_model(version, weights_dir=weights_dir)
except LocalEntryNotFoundError as exc:
raise RIFEWeightsUnavailableError(f"MLX RIFE {version} weights are unavailable: {exc}") from exc
except Exception as exc: # noqa: BLE001 - preserve exact backend failure.
raise RIFEBackendError(f"Failed to load MLX RIFE {version}: {exc}") from exc
def interpolate_pair(
frame_a: np.ndarray,
frame_b: np.ndarray,
timestep: float = 0.5,
*,
model=None,
scale: float = 1.0,
) -> np.ndarray:
"""Interpolate one RGB frame between two input RGB frames."""
if not 0.0 < timestep < 1.0:
raise ValueError(f"timestep must be inside (0, 1), got {timestep}")
img0 = _require_hwc_rgb(frame_a, 0)
img1 = _require_hwc_rgb(frame_b, 1)
if img0.shape != img1.shape:
raise ValueError(f"frame shapes must match, got {img0.shape} and {img1.shape}")
if model is None:
model = load_model()
try:
try:
from fastvideo.third_party.rife_mlx.pipeline_mlx import interpolate_pair as _interpolate_pair
except ImportError:
from rife_mlx.pipeline_mlx import interpolate_pair as _interpolate_pair
return _interpolate_pair(model, img0, img1, timestep=timestep, scale=scale)
except Exception as exc: # noqa: BLE001 - preserve exact backend failure.
raise RIFEBackendError(f"MLX RIFE interpolation failed at timestep={timestep}: {exc}") from exc
def interpolate(
frames: list[np.ndarray] | Iterable[np.ndarray],
factor: int = 2,
*,
model=None,
scale: float = 1.0,
) -> list[np.ndarray]:
"""Return an Nx interpolated frame list.
For ``len(frames)=41`` and ``factor=2``, the output length is 81:
``(41 - 1) * 2 + 1``. Original keyframes are preserved in order and RIFE
fills ``factor - 1`` intermediate timesteps between each adjacent pair.
"""
frame_list = [_require_hwc_rgb(frame, idx) for idx, frame in enumerate(frames)]
if factor < 1:
raise ValueError(f"factor must be >= 1, got {factor}")
if len(frame_list) < 2 or factor == 1:
return [frame.copy() for frame in frame_list]
first_shape = frame_list[0].shape
for idx, frame in enumerate(frame_list[1:], start=1):
if frame.shape != first_shape:
raise ValueError(f"all frames must have the same shape; frame 0={first_shape}, frame {idx}={frame.shape}")
if model is None:
model = load_model()
out: list[np.ndarray] = []
for left, right in zip(frame_list[:-1], frame_list[1:], strict=True):
out.append(left)
for step in range(1, factor):
out.append(interpolate_pair(left, right, step / factor, model=model, scale=scale))
out.append(frame_list[-1])
return out
+139
View File
@@ -0,0 +1,139 @@
# SPDX-License-Identifier: Apache-2.0
"""On-device (MLX) DMD sampling for the FastWan runtime.
The hybrid proof-of-concept ran the FastWan DiT in MLX but bounced every
denoising step back through torch/NumPy to run the DMD scheduler math
(``MLX -> np.array -> torch (CPU) -> np.array -> MLX``). That host round-trip
forces a full device sync per step and defeats MLX's lazy graph execution.
This module mirrors the exact DMD arithmetic from
``fastvideo/models/utils.py::pred_noise_to_pred_video`` and
``FlowMatchEulerDiscreteScheduler.add_noise`` while keeping every large tensor
on the MLX device. The schedule lookup (``argmin`` over the ~1000-entry
training schedule) is done once on the host in NumPy: it is tiny, it is the
same value torch would compute, and it sidesteps the reduction-index quirk that
affects ``argmin`` on the Metal/MPS backends (see the CPU fallbacks in
``fastvideo/models/utils.py`` and ``scheduling_flow_match_euler_discrete.py``).
Because the DMD loop applies a single scalar timestep per step, ``sigma`` is a
scalar and the update is a plain elementwise affine combination — no
permute/flatten reshaping is required.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import numpy as np
if TYPE_CHECKING: # pragma: no cover - typing only
import mlx.core as mx
@dataclass(frozen=True)
class MLXDMDSchedule:
"""Host-side copy of a flow-match scheduler's ``(sigmas, timesteps)``.
Holds the full training schedule so a DMD timestep (e.g. one of
``1000, 757, 522``) can be mapped to its flow-match ``sigma`` with the same
nearest-timestep lookup the torch path uses.
"""
sigmas: np.ndarray
timesteps: np.ndarray
@classmethod
def from_torch_scheduler(cls, scheduler: Any) -> MLXDMDSchedule:
"""Snapshot ``scheduler.sigmas`` / ``scheduler.timesteps`` to NumPy.
Matches ``pred_noise_to_pred_video`` / ``add_noise``, which index the
scheduler's *full* training schedule (not the per-inference subset).
"""
sigmas = scheduler.sigmas.detach().to("cpu").double().numpy()
timesteps = scheduler.timesteps.detach().to("cpu").double().numpy()
return cls(sigmas=np.asarray(sigmas), timesteps=np.asarray(timesteps))
def sigma_for(self, timestep: float) -> float:
"""
Find the sigma associated with the scheduled timestep nearest to the given timestep.
Parameters:
timestep (float): Timestep for which to find the nearest scheduled sigma.
Returns:
float: Sigma associated with the nearest scheduled timestep.
"""
idx = int(np.argmin(np.abs(self.timesteps - float(timestep))))
return float(self.sigmas[idx])
def pred_noise_to_pred_video(
pred_noise: mx.array,
noise_input_latent: mx.array,
sigma: float,
) -> mx.array:
"""
Compute the clean latent prediction from a flow-matching noise prediction.
Parameters:
pred_noise (mx.array): Predicted noise.
noise_input_latent (mx.array): Noised latent input.
sigma (float): Noise level used for the prediction.
Returns:
mx.array: Predicted clean latent.
"""
return noise_input_latent - sigma * pred_noise
def add_noise(
clean_latent: mx.array,
noise: mx.array,
sigma: float,
) -> mx.array:
"""Flow-match forward noising, mirroring the scheduler's ``add_noise``.
``sample = (1 - sigma) * clean_latent + sigma * noise``.
"""
return (1.0 - sigma) * clean_latent + sigma * noise
def dmd_step(
*,
latents: mx.array,
noise_input_latent: mx.array,
pred_noise: mx.array,
schedule: MLXDMDSchedule,
timestep: float,
next_timestep: float | None,
noise: mx.array | None = None,
) -> mx.array:
"""
Compute one DMD sampling update, optionally re-noising the clean latent prediction.
Args:
latents: Retained for call-site compatibility and not used in the update.
noise_input_latent: Noisy latent used to compute the clean prediction.
pred_noise: Predicted noise or velocity.
schedule: Flow-matching schedule used to map timesteps to sigmas.
timestep: Current sampling timestep.
next_timestep: Timestep for the next update, or `None` for the final step.
noise: Fresh noise used for re-noising intermediate steps.
Returns:
The re-noised latent for the next step or the clean latent prediction on
the final step.
Raises:
ValueError: If `next_timestep` is provided without `noise`.
"""
del latents # symmetry with the torch loop; not needed for the math.
sigma = schedule.sigma_for(timestep)
pred_video = pred_noise_to_pred_video(pred_noise, noise_input_latent, sigma)
if next_timestep is None:
return pred_video
if noise is None:
raise ValueError("dmd_step requires `noise` when `next_timestep` is set (re-noise step).")
sigma_next = schedule.sigma_for(next_timestep)
return add_noise(pred_video, noise, sigma_next)
+191
View File
@@ -0,0 +1,191 @@
# SPDX-License-Identifier: Apache-2.0
"""Optional TAEHV decode helpers for Apple Silicon FastWan experiments.
The TAEHV module itself is vendored at ``fastvideo/third_party/taehv`` (MIT,
madebyollin/taehv), so no source code is downloaded or executed at runtime.
Only the ``taew2_1.pth`` checkpoint is fetched on demand, and its sha256 is
verified before use.
"""
from __future__ import annotations
import hashlib
import importlib.util
import urllib.request
from pathlib import Path
import numpy as np
TAEW2_1_CHECKPOINT_URL = "https://raw.githubusercontent.com/madebyollin/taehv/main/taew2_1.pth"
# sha256 of the upstream taew2_1.pth this module was validated against
# (fetched 2026-07-02). If upstream publishes a new checkpoint, revalidate the
# decode path and update this pin.
TAEW2_1_CHECKPOINT_SHA256 = "d26151e76cdc2c9424bef988de874b33d9a53f30ef3060cd556c429c469c797e"
# Wan2.2 5B (z_dim=48) — see madebyollin/taehv taew2_2.pth; prefer
# ``fastvideo.mlx_runtime.wan_vae.ensure_taehv_checkpoint(z_dim=48)`` for new code.
TAEW2_2_CHECKPOINT_URL = ("https://raw.githubusercontent.com/madebyollin/taehv/"
"563f40bdc820ed86bcad72ea515ee48f06bd22ec/taew2_2.pth")
def _default_cache_dir() -> Path:
"""Return the default directory used to cache TAEHV checkpoints.
Returns:
Path: The TAEHV checkpoint cache directory under the user's home directory.
"""
return Path.home() / ".cache" / "fastvideo" / "taehv"
def _sha256(path: Path) -> str:
"""
Compute the SHA-256 digest of a file.
Parameters:
path (Path): The file whose contents are hashed.
Returns:
str: The file's SHA-256 digest in hexadecimal form.
"""
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def _verify_checkpoint(path: Path) -> None:
"""Verify that a TAEW2.1 checkpoint matches the expected SHA-256 digest.
Parameters:
path (Path): Path to the checkpoint file.
Raises:
RuntimeError: If the checkpoint digest does not match the expected value.
"""
actual = _sha256(path)
if actual != TAEW2_1_CHECKPOINT_SHA256:
raise RuntimeError(f"TAEHV checkpoint at {path} failed sha256 verification "
f"(expected {TAEW2_1_CHECKPOINT_SHA256}, got {actual}). "
"Delete the file to re-download it, or pass --taehv-checkpoint-path "
"pointing at a checkpoint you trust.")
def ensure_taew2_1_checkpoint(checkpoint_path: Path | None = None) -> Path:
"""
Ensure the TAEW2.1 checkpoint is available locally.
A caller-provided path is treated as trusted and is only checked for existence.
The module-managed cached checkpoint is verified against the pinned SHA-256 digest
after downloading or before reuse.
Parameters:
checkpoint_path (Path | None): Optional path to a caller-provided checkpoint.
Returns:
Path: The available checkpoint path.
Raises:
FileNotFoundError: If a caller-provided checkpoint does not exist.
RuntimeError: If a module-managed checkpoint fails verification.
"""
if checkpoint_path is not None:
if not checkpoint_path.exists():
raise FileNotFoundError(f"TAEHV checkpoint not found: {checkpoint_path}")
return checkpoint_path
checkpoint_path = _default_cache_dir() / "taew2_1.pth"
if not checkpoint_path.exists():
checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
print(f"Downloading {TAEW2_1_CHECKPOINT_URL} -> {checkpoint_path}")
import socket
import tempfile
# Download to a temporary file, verify, then atomically rename.
with tempfile.NamedTemporaryFile(
mode="wb",
dir=checkpoint_path.parent,
prefix=".tmp_taew2_1_",
suffix=".pth",
delete=False,
) as tmp_file:
tmp_path = Path(tmp_file.name)
try:
old_timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(300)
try:
urllib.request.urlretrieve(
TAEW2_1_CHECKPOINT_URL,
tmp_path, # noqa: S310 - pinned public artifact, hash-verified below.
)
finally:
socket.setdefaulttimeout(old_timeout)
_verify_checkpoint(tmp_path)
tmp_path.replace(checkpoint_path)
except Exception:
tmp_path.unlink(missing_ok=True)
raise
else:
_verify_checkpoint(checkpoint_path)
return checkpoint_path
def _load_taehv_class(source_path: Path | None):
"""Load the TAEHV class from the vendored implementation or a local source override.
Parameters:
source_path (Path | None): Path to a local Python file defining `TAEHV`; `None` selects the vendored implementation.
Returns:
The loaded `TAEHV` class.
Raises:
RuntimeError: If the specified source cannot be loaded.
"""
if source_path is None:
from fastvideo.third_party.taehv import TAEHV
return TAEHV
# Explicit local override for experimenting with a modified TAEHV; this is
# a user-supplied file on disk, never something this module downloads.
spec = importlib.util.spec_from_file_location("fastvideo_external_taehv", source_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load TAEHV source from {source_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.TAEHV
def decode_latents_to_video_taehv(
*,
latents_np: np.ndarray,
output_path: Path,
fps: int,
device,
dtype,
parallel: bool,
source_path: Path | None = None,
checkpoint_path: Path | None = None,
) -> None:
"""Decode Wan/FastWan diffusion latents with TAEW2.1 and export MP4.
TAEHV's Wan wrapper expects the diffusion latents directly, without applying
the standard Wan VAE's `latents_mean` / `latents_std` shift.
"""
import torch
from diffusers.utils import export_to_video
checkpoint_path = ensure_taew2_1_checkpoint(checkpoint_path)
TAEHV = _load_taehv_class(source_path)
taehv = TAEHV(str(checkpoint_path)).to(device=device, dtype=dtype)
taehv.eval()
latents = torch.from_numpy(latents_np).to(device=device, dtype=dtype)
with torch.no_grad():
video_ntchw = taehv.decode_video(
latents.transpose(1, 2),
parallel=parallel,
show_progress_bar=False,
)
video = video_ntchw.transpose(1, 2)
video_np = video[0].permute(1, 2, 3, 0).float().cpu().numpy()
output_path.parent.mkdir(parents=True, exist_ok=True)
export_to_video(video_np, str(output_path), fps=fps)
+286
View File
@@ -0,0 +1,286 @@
# SPDX-License-Identifier: Apache-2.0
"""Wan2.2-TI2V-5B dense MLX runtime — Track D.
The Wan2.2 TI2V-5B (FullAttn) differs from the ported Wan2.1-T2V only in:
- **Scale** (24 heads x 128, hidden 3072, ffn 14336) — pure config, block math
identical, so the dense loader ``mlx_dit_from_diffusers_safetensors`` loads the
weights unchanged and we re-wrap the blocks here.
- **Per-token timestep conditioning** (``expand_timesteps=True``): the timestep is
``[batch, seq_len]`` (a level per patch token — how TI2V keeps the conditioning
image frame at t=0 while the video frames are noised). ``timestep_proj`` becomes
``[batch, seq_len, 6, dim]`` and the block/output modulation is per-token
(``[B, L, dim]``), a direct broadcast — this module implements exactly that.
I2V rides on the same forward: encode the image, replace the first latent frame,
and set that frame's timestep to 0 (handled by the caller / sampler). See
``docs/design/ti2v_5b_port_guide.md``.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
from collections.abc import Callable
from fastvideo.logger import init_logger
from fastvideo.mlx_runtime.fastwan import (
MLXWanT2VCrossAttention,
gelu_tanh,
layer_norm,
linear,
mlx_dit_from_diffusers_safetensors,
rms_norm,
silu,
timestep_embedding,
weight_dtype,
)
if TYPE_CHECKING:
import mlx.core as mx
logger = init_logger(__name__)
class MLXWan22TransformerBlock:
"""Dense Wan block with per-token (``[B, L, dim]``) timestep modulation."""
def __init__(
self,
weights: dict[str, mx.array],
*,
dim: int,
ffn_dim: int,
num_heads: int,
eps: float = 1e-6,
):
self.weights = weights
self.dim = dim
self.ffn_dim = ffn_dim
self.num_heads = num_heads
self.head_dim = dim // num_heads
self.eps = eps
self.attn2 = MLXWanT2VCrossAttention(weights, dim=dim, num_heads=num_heads, eps=eps)
def __call__(self, hidden_states, encoder_hidden_states, timestep_proj, cos, sin) -> mx.array:
import mlx.core as mx
orig_dtype = hidden_states.dtype
batch = hidden_states.shape[0]
# timestep_proj: [B, L, 6, dim] -> six per-token [B, L, dim] modulations.
e = self.weights["scale_shift_table"][None] + timestep_proj.astype(mx.float32)
shift_msa, scale_msa, gate_msa, c_shift_msa, c_scale_msa, c_gate_msa = [
part.squeeze(2) for part in mx.split(e, 6, axis=2)
]
# 1. Self-attention (dense, bidirectional) with per-token modulation.
norm_hidden = layer_norm(hidden_states.astype(mx.float32), eps=self.eps)
norm_hidden = (norm_hidden * (1.0 + scale_msa) + shift_msa).astype(orig_dtype)
query = linear(norm_hidden, self.weights["to_q.weight"], self.weights.get("to_q.bias"))
key = linear(norm_hidden, self.weights["to_k.weight"], self.weights.get("to_k.bias"))
value = linear(norm_hidden, self.weights["to_v.weight"], self.weights.get("to_v.bias"))
query = rms_norm(query, self.weights["norm_q.weight"],
eps=self.eps).reshape(batch, -1, self.num_heads, self.head_dim)
key = rms_norm(key, self.weights["norm_k.weight"], eps=self.eps).reshape(batch, -1, self.num_heads,
self.head_dim)
value = value.reshape(batch, -1, self.num_heads, self.head_dim)
from fastvideo.mlx_runtime.fastwan import apply_rotary_emb
query = apply_rotary_emb(query, cos, sin, is_neox_style=False)
key = apply_rotary_emb(key, cos, sin, is_neox_style=False)
attn = mx.fast.scaled_dot_product_attention(
query.transpose(0, 2, 1, 3),
key.transpose(0, 2, 1, 3),
value.transpose(0, 2, 1, 3),
scale=self.head_dim**-0.5,
).transpose(0, 2, 1, 3)
attn = attn.reshape(batch, -1, self.dim)
attn = linear(attn, self.weights["to_out.weight"], self.weights.get("to_out.bias"))
hidden_states = hidden_states + (attn * gate_msa).astype(orig_dtype)
norm_hidden = layer_norm(hidden_states.astype(mx.float32),
weight=self.weights["self_attn_residual_norm.norm.weight"],
bias=self.weights["self_attn_residual_norm.norm.bias"],
eps=self.eps).astype(orig_dtype)
# 2. Cross-attention, then per-token shift/scale modulation.
cross = self.attn2(norm_hidden, encoder_hidden_states)
hidden_states = hidden_states + cross
norm_hidden = layer_norm(hidden_states.astype(mx.float32), eps=self.eps)
norm_hidden = (norm_hidden * (1.0 + c_scale_msa) + c_shift_msa).astype(orig_dtype)
# 3. Feed-forward with per-token gate.
ff = linear(norm_hidden, self.weights["ffn.fc_in.weight"], self.weights.get("ffn.fc_in.bias"))
ff = gelu_tanh(ff)
ff = linear(ff, self.weights["ffn.fc_out.weight"], self.weights.get("ffn.fc_out.bias"))
hidden_states = hidden_states + (ff * c_gate_msa).astype(orig_dtype)
return hidden_states.astype(orig_dtype)
class MLXWan22DiT:
"""Wan2.2-TI2V-5B dense DiT with per-token timestep conditioning."""
def __init__(
self,
weights: dict[str, mx.array],
blocks: list[MLXWan22TransformerBlock],
config: dict,
*,
compile: bool = False,
) -> None:
import os
self.weights = weights
self.blocks = blocks
self.config = config
self.num_heads = int(config["num_attention_heads"])
self.head_dim = int(config["attention_head_dim"])
self.hidden_size = self.num_heads * self.head_dim
self.freq_dim = int(config["freq_dim"])
self.patch_size = tuple(config["patch_size"])
self.out_channels = int(config["out_channels"])
self.eps = float(config.get("eps", 1e-6))
self._enable_compile = compile or os.environ.get("FASTVIDEO_MLX_COMPILE", "0") == "1"
self._compiled_forward: Callable[..., Any] | None = None
self._compiled_signature: tuple | None = None
def _patch_embed(self, hidden_states) -> mx.array:
batch, channels, frames, height, width = hidden_states.shape
pt, ph, pw = self.patch_size
patch_dim = channels * pt * ph * pw
x = hidden_states.reshape(batch, channels, frames // pt, pt, height // ph, ph, width // pw, pw)
x = x.transpose(0, 2, 4, 6, 1, 3, 5, 7).reshape(batch, -1, patch_dim)
return linear(x, self.weights["patch_embedding.weight"], self.weights.get("patch_embedding.bias"))
def _condition(self, timestep, encoder_hidden_states) -> tuple:
"""Per-token conditioning. ``timestep`` is ``[B, L]`` (one level per token)."""
batch, seq = timestep.shape
t_freq = timestep_embedding(timestep.reshape(-1), self.freq_dim).astype(
weight_dtype(self.weights["condition_embedder.time_embedder.linear_1.weight"]))
temb = linear(t_freq, self.weights["condition_embedder.time_embedder.linear_1.weight"],
self.weights["condition_embedder.time_embedder.linear_1.bias"])
temb = silu(temb)
temb = linear(temb, self.weights["condition_embedder.time_embedder.linear_2.weight"],
self.weights["condition_embedder.time_embedder.linear_2.bias"])
timestep_proj = linear(silu(temb), self.weights["condition_embedder.time_proj.weight"],
self.weights["condition_embedder.time_proj.bias"])
timestep_proj = timestep_proj.reshape(batch, seq, 6, self.hidden_size)
ehs = linear(encoder_hidden_states, self.weights["condition_embedder.text_embedder.linear_1.weight"],
self.weights["condition_embedder.text_embedder.linear_1.bias"])
ehs = gelu_tanh(ehs)
ehs = linear(ehs, self.weights["condition_embedder.text_embedder.linear_2.weight"],
self.weights["condition_embedder.text_embedder.linear_2.bias"])
temb_out = temb.reshape(batch, seq, self.hidden_size)
return temb_out, timestep_proj, ehs
def _output(self, hidden_states, temb_out, *, batch, frames, height, width) -> mx.array:
import mlx.core as mx
pt, ph, pw = self.patch_size
post_pt, post_ph, post_pw = frames // pt, height // ph, width // pw
# Per-token output modulation: scale_shift_table[1,2,dim] + temb[B,L,1,dim].
e = self.weights["scale_shift_table"][None] + temb_out[:, :, None, :].astype(mx.float32)
shift, scale = [part.squeeze(2) for part in mx.split(e, 2, axis=2)]
norm = layer_norm(hidden_states.astype(mx.float32), eps=self.eps)
norm = (norm * (1.0 + scale) + shift).astype(weight_dtype(self.weights["proj_out.weight"]))
out = linear(norm, self.weights["proj_out.weight"], self.weights["proj_out.bias"])
out = out.reshape(batch, post_pt, post_ph, post_pw, pt, ph, pw, self.out_channels)
out = out.transpose(0, 7, 1, 4, 2, 5, 3, 6)
return out.reshape(batch, self.out_channels, frames, height, width)
def _forward(self, hidden_states, encoder_hidden_states, timestep, cos, sin) -> mx.array:
batch, _, frames, height, width = hidden_states.shape
hidden = self._patch_embed(hidden_states)
temb_out, timestep_proj, ehs = self._condition(timestep, encoder_hidden_states)
for block in self.blocks:
hidden = block(hidden, ehs, timestep_proj, cos, sin)
return self._output(hidden, temb_out, batch=batch, frames=frames, height=height, width=width)
def __call__(self, hidden_states, encoder_hidden_states, timestep, freqs_cis) -> mx.array:
cos, sin = freqs_cis
if self._enable_compile and cos is not None:
import mlx.core as mx
# One traced graph per input signature, and each pins its own copy of
# the quantized weights. --refine denoises at two resolutions, so
# keeping both alive doubles resident DiT memory. Retire the previous
# graph when the signature changes.
signature = (hidden_states.shape, encoder_hidden_states.shape, timestep.shape)
if self._compiled_forward is not None and signature != self._compiled_signature:
self._compiled_forward = None
self._compiled_signature = None
mx.clear_cache()
if self._compiled_forward is None:
self._compiled_forward = mx.compile(self._forward)
self._compiled_signature = signature
compiled_forward = self._compiled_forward
try:
return compiled_forward(hidden_states, encoder_hidden_states, timestep, cos, sin)
except Exception as exc: # noqa: BLE001 - some graphs may not trace; fall back to eager.
logger.warning(
"Wan2.2 mx.compile forward failed (%s); falling back to eager execution.",
exc,
)
self._enable_compile = False
self._compiled_forward = None
self._compiled_signature = None
return self._forward(hidden_states, encoder_hidden_states, timestep, cos, sin)
def mlx_wan22_dit_from_diffusers_safetensors(
checkpoint_path: str | Path,
config_path: str | Path,
*,
dtype: str = "fp16",
num_blocks: int | None = None,
quantization=None,
compile: bool = False,
) -> MLXWan22DiT:
"""Load Wan2.2-TI2V-5B (FullAttn) into ``MLXWan22DiT`` via the dense loader."""
dense = mlx_dit_from_diffusers_safetensors(checkpoint_path,
config_path,
dtype=dtype,
num_blocks=num_blocks,
quantization=quantization)
inner_dim = int(dense.config["num_attention_heads"]) * int(dense.config["attention_head_dim"])
blocks = [
MLXWan22TransformerBlock(block.weights,
dim=inner_dim,
ffn_dim=int(dense.config["ffn_dim"]),
num_heads=int(dense.config["num_attention_heads"]),
eps=float(dense.config.get("eps", 1e-6))) for block in dense.blocks
]
return MLXWan22DiT(dense.weights, blocks, dense.config, compile=compile)
def mlx_wan22_dit_from_mlx_checkpoint(
checkpoint_dir: str | Path,
*,
compile: bool = False,
) -> MLXWan22DiT:
"""Rewrap a persisted MLX DiT checkpoint with Wan2.2 conditioning.
The generic checkpoint loader intentionally rebuilds ``MLXWanDiT`` because
it is also used by the Wan2.1 runtime. Wan2.2 TI2V has the same weight
layout but needs per-token timestep modulation, so callers must rewrap the
loaded weights and blocks as :class:`MLXWan22DiT` before sampling.
"""
from fastvideo.mlx_runtime.checkpoint import load_mlx_dit_checkpoint
dense = load_mlx_dit_checkpoint(checkpoint_dir)
inner_dim = int(dense.config["num_attention_heads"]) * int(dense.config["attention_head_dim"])
blocks = [
MLXWan22TransformerBlock(
block.weights,
dim=inner_dim,
ffn_dim=int(dense.config["ffn_dim"]),
num_heads=int(dense.config["num_attention_heads"]),
eps=float(dense.config.get("eps", 1e-6)),
) for block in dense.blocks
]
return MLXWan22DiT(dense.weights, blocks, dense.config, compile=compile)
+113
View File
@@ -0,0 +1,113 @@
# SPDX-License-Identifier: Apache-2.0
"""Dense DMD sampling for MLXWan22DiT (Wan2.2 per-token timestep).
Matches the FastVideo pipeline's warped DMD schedule (``warp_denoising_step=True``,
``dmd_denoising_steps=[1000,757,522]``, ``flow_shift=5.0`` for TI2V-5B) rather
than treating raw step indices as continuous timesteps (a bug in early demos).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from collections.abc import Sequence
import numpy as np
from fastvideo.mlx_runtime.sampling import MLXDMDSchedule, dmd_step, pred_noise_to_pred_video
if TYPE_CHECKING:
import mlx.core as mx
from fastvideo.mlx_runtime.wan22 import MLXWan22DiT
def build_wan22_dmd_schedule(
dmd_denoising_steps: Sequence[int] | None = None,
*,
flow_shift: float = 5.0,
warp_denoising_step: bool = True,
) -> tuple[MLXDMDSchedule, list[float]]:
"""
Build the flow-matching schedule and continuous timesteps used for Wan2.2 DMD sampling.
Parameters:
dmd_denoising_steps (Sequence[int] | None): Denoising step values to use; defaults to 1000, 757, and 522.
flow_shift (float): Flow-matching shift applied when constructing the schedule.
warp_denoising_step (bool): Whether to convert denoising steps to scheduler-warped continuous timesteps.
Returns:
tuple[MLXDMDSchedule, list[float]]: The DMD schedule and corresponding continuous timesteps.
"""
import torch
from fastvideo.models.schedulers.scheduling_flow_match_euler_discrete import FlowMatchEulerDiscreteScheduler
steps = list(dmd_denoising_steps or [1000, 757, 522])
scheduler = FlowMatchEulerDiscreteScheduler(shift=flow_shift)
schedule = MLXDMDSchedule.from_torch_scheduler(scheduler)
step_idx = torch.tensor(steps, dtype=torch.long)
if warp_denoising_step:
warped = torch.cat((scheduler.timesteps.cpu(), torch.tensor([0.0], dtype=torch.float32)))
timesteps = [float(t) for t in warped[1000 - step_idx]]
else:
timesteps = [float(s) for s in steps]
return schedule, timesteps
def sample_wan22_dmd(
model: MLXWan22DiT,
encoder_hidden_states: mx.array,
noise_latents: mx.array,
freqs_cis: tuple,
*,
dmd_denoising_steps: Sequence[int] | None = None,
flow_shift: float = 5.0,
warp_denoising_step: bool = True,
seed: int = 0,
) -> mx.array:
"""
Generate clean video latents from noisy latents using iterative DMD denoising.
Parameters:
noise_latents (mx.array): Initial noisy video latents.
freqs_cis (tuple): Rotary positional frequency tensors used by the model.
dmd_denoising_steps (Sequence[int] | None): DMD denoising steps, or the default schedule when omitted.
flow_shift (float): Flow-matching schedule shift.
warp_denoising_step (bool): Whether to warp the denoising timesteps.
seed (int): Seed for reproducible intermediate re-noising.
Returns:
mx.array: Denoised video latents.
"""
import mlx.core as mx
schedule, timesteps = build_wan22_dmd_schedule(dmd_denoising_steps,
flow_shift=flow_shift,
warp_denoising_step=warp_denoising_step)
# NumPy RNG so re-noise is bit-reproducible across MLX / torch A/B dumps.
renoise_rng = np.random.default_rng(seed)
latents = noise_latents
batch, _c, frames, height, width = latents.shape
pt, ph, pw = model.patch_size
tokens = (frames // pt) * (height // ph) * (width // pw)
last = len(timesteps) - 1
for i, t in enumerate(timesteps):
ts = mx.full((batch, tokens), float(t), dtype=mx.float32)
pred = model(latents.astype(mx.float16), encoder_hidden_states, ts, freqs_cis)
ni = latents.astype(mx.float32)
pn = pred.astype(mx.float32)
if i < last:
renoise = mx.array(renoise_rng.standard_normal(tuple(latents.shape)).astype(np.float32))
latents = dmd_step(
latents=ni,
noise_input_latent=ni,
pred_noise=pn,
schedule=schedule,
timestep=float(t),
next_timestep=float(timesteps[i + 1]),
noise=renoise,
).astype(latents.dtype)
else:
latents = pred_noise_to_pred_video(pn, ni, schedule.sigma_for(float(t))).astype(latents.dtype)
mx.eval(latents)
return latents
+557
View File
@@ -0,0 +1,557 @@
# SPDX-License-Identifier: Apache-2.0
"""Wan VAE decode helpers for Apple Silicon MLX inference.
Two decode backends:
1. **TAEHV (primary / fast)** — Tiny AutoEncoder (madebyollin/taehv). Fully
MLX-native Conv2d path. ``taew2_1.pth`` for Wan2.1 (z_dim=16),
``taew2_2.pth`` for Wan2.2 5B (z_dim=48, patch_size=2). Expected decode
wall-clock ~seconds vs ~minutes for the full 3D VAE on MPS.
2. **Full AutoencoderKLWan (reference / quality)** — denormalize with
``latents_mean`` / ``latents_std`` then torch decode (MPS preferred). Used
for parity gates and when TAEHV is unavailable. A pure-MLX 3D-conv port of
the residual Wan2.2 decoder is left as follow-up (causal feat-cache +
residual up blocks are large); TAEHV covers the product latency path.
Diffusion latents from the DiT are **not** mean/std-normalized for TAEHV
(matching ``taehv_decode.py``); full VAE decode **does** denormalize first
(matching ``mlx_wan_prompt_to_video.decode_latents_to_video``).
"""
from __future__ import annotations
import hashlib
import json
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
import numpy as np
from fastvideo.mlx_runtime.memory import cleanup_mlx, cleanup_torch_mps
GIB = 1024**3
TAEW2_1_URL = "https://raw.githubusercontent.com/madebyollin/taehv/main/taew2_1.pth"
TAEW2_2_URL = ("https://raw.githubusercontent.com/madebyollin/taehv/"
"563f40bdc820ed86bcad72ea515ee48f06bd22ec/taew2_2.pth")
# Validated 2026-07-02 / 2026-07-09 against upstream madebyollin/taehv.
TAEW2_1_SHA256 = "d26151e76cdc2c9424bef988de874b33d9a53f30ef3060cd556c429c469c797e"
TAEW2_2_SHA256 = "d053e216ca50e2bb837bbcd79b85f0366bea00e5938025572382a773b74c559a"
DecodeBackend = Literal["taehv", "taehv-torch", "wan-vae"]
def _cache_dir() -> Path:
"""Return the local directory used to cache TAEHV files."""
return Path.home() / ".cache" / "fastvideo" / "taehv"
def _sha256(path: Path) -> str:
"""Compute the SHA-256 digest of a file.
Parameters:
path (Path): Path to the file to hash.
Returns:
str: Lowercase hexadecimal SHA-256 digest.
"""
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def _verify_checkpoint(path: Path, expected_digest: str) -> None:
"""
Verify a checkpoint's SHA-256 digest against a required expected digest.
Parameters:
path (Path): Path to the checkpoint file.
expected_digest (str): Expected lowercase SHA-256 digest.
Raises:
RuntimeError: If verification is enabled and the checkpoint digest does not match.
"""
if len(expected_digest) != 64 or any(char not in "0123456789abcdef" for char in expected_digest):
raise ValueError("A valid lowercase SHA-256 digest is required for bundled TAEHV checkpoints")
actual = _sha256(path)
if actual != expected_digest:
raise RuntimeError(f"TAEHV checkpoint at {path} failed sha256 verification "
f"(expected {expected_digest}, got {actual}). "
"Delete the file to re-download it.")
def ensure_taehv_checkpoint(*, z_dim: int, checkpoint_path: Path | None = None) -> Path:
"""
Return a validated TAEHV checkpoint for the specified latent channel count.
Parameters:
z_dim (int): Number of latent channels, supported values are 16 and 48.
checkpoint_path (Path | None): Optional existing checkpoint path to validate and use.
Returns:
Path: Path to the validated TAEHV checkpoint.
Raises:
FileNotFoundError: If the supplied checkpoint path does not exist.
ValueError: If no checkpoint is mapped to the specified latent channel count.
"""
if checkpoint_path is not None:
if not checkpoint_path.exists():
raise FileNotFoundError(f"TAEHV checkpoint not found: {checkpoint_path}")
return checkpoint_path
if z_dim == 16:
name, url, expect = "taew2_1.pth", TAEW2_1_URL, TAEW2_1_SHA256
elif z_dim == 48:
name, url, expect = "taew2_2.pth", TAEW2_2_URL, TAEW2_2_SHA256
else:
raise ValueError(f"No TAEHV checkpoint mapped for z_dim={z_dim} (supported: 16, 48)")
path = _cache_dir() / name
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
print(f"Downloading {url} -> {path}")
import socket
import tempfile
# Download to a temporary file, verify, then atomically rename.
with tempfile.NamedTemporaryFile(
mode="wb",
dir=path.parent,
prefix=f".tmp_{name}_",
suffix=".pth",
delete=False,
) as tmp_file:
tmp_path = Path(tmp_file.name)
try:
old_timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(300)
try:
urllib.request.urlretrieve(url, tmp_path) # noqa: S310 - public pinned artifact.
finally:
socket.setdefaulttimeout(old_timeout)
_verify_checkpoint(tmp_path, expect)
tmp_path.replace(path)
except Exception:
tmp_path.unlink(missing_ok=True)
raise
else:
_verify_checkpoint(path, expect)
return path
@dataclass(frozen=True)
class WanVAEConfigView:
"""Minimal config fields needed for denormalize + spatial scale."""
z_dim: int
latents_mean: tuple[float, ...]
latents_std: tuple[float, ...]
scale_factor_spatial: int = 8
scale_factor_temporal: int = 4
patch_size: int | None = None
vae_dir: Path | None = None
@classmethod
def from_vae_dir(cls, vae_dir: Path) -> WanVAEConfigView:
"""
Load Wan VAE configuration values from a directory.
Parameters:
vae_dir (Path): Directory containing the VAE ``config.json`` file.
Returns:
WanVAEConfigView: Configuration loaded from the VAE directory.
"""
cfg = json.loads((vae_dir / "config.json").read_text())
return cls(
z_dim=int(cfg["z_dim"]),
latents_mean=tuple(float(x) for x in cfg["latents_mean"]),
latents_std=tuple(float(x) for x in cfg["latents_std"]),
scale_factor_spatial=int(cfg.get("scale_factor_spatial", 8)),
scale_factor_temporal=int(cfg.get("scale_factor_temporal", 4)),
patch_size=cfg.get("patch_size"),
vae_dir=vae_dir,
)
def denormalize_latents_np(latents: np.ndarray, config: WanVAEConfigView) -> np.ndarray:
"""
Denormalize Wan VAE latent values using the configured means and standard deviations.
Parameters:
latents (np.ndarray): Latent values in normalized form.
config (WanVAEConfigView): Wan VAE latent statistics.
Returns:
np.ndarray: Denormalized latent values as float32.
"""
mean = np.asarray(config.latents_mean, dtype=np.float32).reshape(1, -1, 1, 1, 1)
std = np.asarray(config.latents_std, dtype=np.float32).reshape(1, -1, 1, 1, 1)
return latents.astype(np.float32) * std + mean
# ---------------------------------------------------------------------------
# MLX TAEHV decoder (Conv2d stack — primary fully-MLX product path)
# ---------------------------------------------------------------------------
def _mlx_conv2d(x: Any, weight: Any, bias: Any, *, stride: int = 1) -> Any:
import mlx.core as mx
# x: NCHW, weight: OIHW
y = mx.conv2d(x.transpose(0, 2, 3, 1), weight.transpose(0, 2, 3, 1), stride=stride, padding=1)
y = y.transpose(0, 3, 1, 2)
if bias is not None:
y = y + bias.reshape(1, -1, 1, 1)
return y
def _mlx_conv2d_1x1(x: Any, weight: Any, bias: Any = None) -> Any:
"""
Applies a 1×1 convolution to an MLX tensor in channel-first layout.
Parameters:
x (Any): Input tensor with shape [batch, channels, height, width].
weight (Any): Convolution weights.
bias (Any, optional): Optional output-channel bias.
Returns:
Any: The convolved tensor with shape [batch, output_channels, height, width].
"""
import mlx.core as mx
y = mx.conv2d(x.transpose(0, 2, 3, 1), weight.transpose(0, 2, 3, 1), stride=1, padding=0)
y = y.transpose(0, 3, 1, 2)
if bias is not None:
y = y + bias.reshape(1, -1, 1, 1)
return y
def _load_torch_state(path: Path) -> dict[str, np.ndarray]:
"""Load a PyTorch state dictionary as NumPy arrays.
Parameters:
path (Path): Path to the PyTorch checkpoint.
Returns:
dict[str, np.ndarray]: State dictionary with tensors converted to NumPy arrays.
"""
import torch
sd = torch.load(path, map_location="cpu", weights_only=True)
return {k: v.detach().float().cpu().numpy() for k, v in sd.items()}
class MLXTAEHVDecoder:
"""Minimal MLX port of TAEHV ``decoder`` (parallel-over-time MemBlocks)."""
def __init__(self, checkpoint_path: Path, *, z_dim: int) -> None:
"""Initialize the TAEHV decoder from a checkpoint for the specified latent dimensionality.
Parameters:
checkpoint_path (Path): Path to the TAEHV checkpoint.
z_dim (int): Number of latent channels, determining the decoder patch size.
"""
import mlx.core as mx
self.checkpoint_path = Path(checkpoint_path)
self.latent_channels = z_dim
# Derive patch_size from z_dim: 48 channels → patch_size=2, 16 → patch_size=1
self.patch_size = 2 if z_dim == 48 else 1
self.image_channels = 3
self.frames_to_trim = 3 # TGrow strides (1,2,2) → 2**2 - 1 for w2.1/w2.2 defaults
sd = _load_torch_state(self.checkpoint_path)
# Patch TGrow kernels like upstream TAEHV.patch_tgrow_layers.
self.weights = {k: mx.array(v) for k, v in sd.items()}
self._n_f = [256, 128, 64, 64]
def decode_ntchw(self, latents_ntchw: Any) -> Any:
"""
Decode latent video batches into clipped RGB frames.
Parameters:
latents_ntchw (Any): Latents with shape ``[N, T, C, H, W]`` and the
decoder's configured latent channel count.
Returns:
Any: Decoded frames with shape ``[N, T_out, 3, H_out, W_out]`` and values
clipped to the range ``[0, 1]``.
"""
import mlx.core as mx
x = latents_ntchw
n, t, c, h, w = x.shape
if c != self.latent_channels:
raise ValueError(f"expected C={self.latent_channels}, got {c}")
x = x.reshape(n * t, c, h, w)
x = self._run_decoder_parallel(x, n=n)
# Pixel-shuffle if patch_size > 1: (NT, 3*p*p, H, W) -> (NT, 3, H*p, W*p)
if self.patch_size > 1:
p = self.patch_size
nt, c_out, hh, ww = x.shape
x = x.reshape(nt, self.image_channels, p, p, hh, ww)
x = x.transpose(0, 1, 4, 2, 5, 3).reshape(nt, self.image_channels, hh * p, ww * p)
_, c_out, hh, ww = x.shape
t_out = x.shape[0] // n
x = x.reshape(n, t_out, c_out, hh, ww)
if self.frames_to_trim > 0 and t_out > self.frames_to_trim:
x = x[:, self.frames_to_trim:]
return mx.clip(x, 0.0, 1.0)
def _run_decoder_parallel(self, x: Any, *, n: int) -> Any:
"""
Apply the TAEHV decoder stack to flattened batch and temporal frames while preserving temporal memory.
"""
import mlx.core as mx
w = self.weights
def memblock(base: int, xx: Any, past: Any) -> Any:
"""
Apply a temporal memory block to the current and past feature tensors.
Parameters:
base (int): Decoder block index used to select the block weights.
xx (Any): Current feature tensor.
past (Any): Past feature tensor concatenated with the current features.
Returns:
Any: Activated feature tensor produced by the memory block.
"""
cat = mx.concatenate([xx, past], axis=1)
h = _mlx_conv2d(cat, w[f"decoder.{base}.conv.0.weight"], w.get(f"decoder.{base}.conv.0.bias"))
h = mx.maximum(h, 0.0)
h = _mlx_conv2d(h, w[f"decoder.{base}.conv.2.weight"], w.get(f"decoder.{base}.conv.2.bias"))
h = mx.maximum(h, 0.0)
h = _mlx_conv2d(h, w[f"decoder.{base}.conv.4.weight"], w.get(f"decoder.{base}.conv.4.bias"))
skip_key = f"decoder.{base}.skip.weight"
skip = _mlx_conv2d_1x1(xx, w[skip_key], None) if skip_key in w else xx
return mx.maximum(h + skip, 0.0)
def upsample2(xx: Any) -> Any:
"""
Upsample a four-dimensional tensor by a factor of two along its spatial dimensions.
Parameters:
xx (Any): Tensor with shape `(N, C, H, W)`.
Returns:
Any: Tensor with shape `(N, C, 2H, 2W)` containing replicated spatial values.
"""
nt, c, h, ww = xx.shape
xx = xx.reshape(nt, c, h, 1, ww, 1)
xx = mx.broadcast_to(xx, (nt, c, h, 2, ww, 2))
return xx.reshape(nt, c, h * 2, ww * 2)
def tgrow(base: int, xx: Any, stride: int) -> Any:
wt = w[f"decoder.{base}.conv.weight"]
out_ch = int(xx.shape[1]) * stride
if int(wt.shape[0]) > out_ch:
wt = wt[-out_ch:]
y = _mlx_conv2d_1x1(xx, wt, None)
if stride == 1:
return y
# TGrow.forward: (NT, C*stride, H, W) -> (NT*stride, C, H, W)
nt, c, h, ww = y.shape
c_in = c // stride
y = y.reshape(nt, stride, c_in, h, ww).transpose(0, 1, 2, 3, 4)
return y.reshape(nt * stride, c_in, h, ww)
def mem_past(xx: Any) -> Any:
"""
Build a temporal memory tensor containing a zero frame followed by the preceding frame at each time step.
Parameters:
xx (Any): Flattened batch and temporal tensor with shape ``(batch * time, channels, height, width)``.
Returns:
Any: Tensor with the same shape as ``xx`` containing the preceding frame for each temporal position.
"""
nt, c, h, ww = xx.shape
t_cur = nt // n
x_ = xx.reshape(n, t_cur, c, h, ww)
# pad one zero frame at t=0, align past[t] = x[t-1]
past = mx.concatenate([mx.zeros_like(x_[:, :1]), x_[:, :-1]], axis=1)
return past.reshape(nt, c, h, ww)
# 0 Clamp, 1 conv, 2 ReLU
x = mx.tanh(x / 3.0) * 3.0
x = _mlx_conv2d(x, w["decoder.1.weight"], w.get("decoder.1.bias"))
x = mx.maximum(x, 0.0)
for mem_idx in (3, 4, 5):
x = memblock(mem_idx, x, mem_past(x))
x = upsample2(x)
x = tgrow(7, x, 1)
x = _mlx_conv2d(x, w["decoder.8.weight"], w.get("decoder.8.bias"))
for mem_idx in (9, 10, 11):
x = memblock(mem_idx, x, mem_past(x))
x = upsample2(x)
x = tgrow(13, x, 2)
x = _mlx_conv2d(x, w["decoder.14.weight"], w.get("decoder.14.bias"))
for mem_idx in (15, 16, 17):
x = memblock(mem_idx, x, mem_past(x))
x = upsample2(x)
x = tgrow(19, x, 2)
x = _mlx_conv2d(x, w["decoder.20.weight"], w.get("decoder.20.bias"))
x = mx.maximum(x, 0.0)
x = _mlx_conv2d(x, w["decoder.22.weight"], w.get("decoder.22.bias"))
return x
def decode_latents_taehv_mlx(
latents_np: np.ndarray,
*,
z_dim: int | None = None,
checkpoint_path: Path | None = None,
) -> np.ndarray:
"""
Decode latent representations with the MLX TAEHV decoder.
Parameters:
latents_np (np.ndarray): Latents arranged as [B, C, T, H, W].
z_dim (int | None): Latent channel dimension used to select the decoder checkpoint.
checkpoint_path (Path | None): Optional path to a TAEHV checkpoint.
Returns:
np.ndarray: Decoded pixels arranged as [B, T, H, W, 3] with values in [0, 1].
Raises:
ValueError: If `latents_np` does not have five dimensions.
"""
import mlx.core as mx
if latents_np.ndim != 5:
raise ValueError(f"expected [B,C,T,H,W], got {latents_np.shape}")
c = latents_np.shape[1]
z = z_dim if z_dim is not None else c
ckpt = ensure_taehv_checkpoint(z_dim=z, checkpoint_path=checkpoint_path)
dec = MLXTAEHVDecoder(ckpt, z_dim=z)
# NTCHW
x = mx.array(latents_np.transpose(0, 2, 1, 3, 4).astype(np.float32))
out = dec.decode_ntchw(x) # N T C H W
mx.eval(out)
arr = np.array(out)
# B T H W C
return arr.transpose(0, 1, 3, 4, 2)
def decode_latents_wan_vae_torch(
latents_np: np.ndarray,
*,
vae_dir: Path,
device: str = "auto",
dtype_name: str = "fp16",
) -> np.ndarray:
"""Full AutoencoderKLWan decode on torch (MPS/CPU) with mean/std denormalize.
Returns pixels ``[B, T, H, W, 3]`` float in [0, 1].
"""
import torch
from diffusers import AutoencoderKLWan
from diffusers.video_processor import VideoProcessor
if device == "auto":
device = "mps" if torch.backends.mps.is_available() else "cpu"
dtype = torch.float16 if dtype_name == "fp16" and device == "mps" else torch.float32
config = WanVAEConfigView.from_vae_dir(vae_dir)
vae = AutoencoderKLWan.from_pretrained(vae_dir, torch_dtype=dtype, local_files_only=True).to(device)
vae.eval()
latents = torch.from_numpy(latents_np.astype(np.float32)).to(device=device, dtype=dtype)
mean = torch.tensor(config.latents_mean, device=device, dtype=dtype).view(1, -1, 1, 1, 1)
inv_std = (1.0 / torch.tensor(config.latents_std, device=device, dtype=dtype)).view(1, -1, 1, 1, 1)
latents = latents / inv_std + mean # matches prompt_to_video path
with torch.no_grad():
video = vae.decode(latents, return_dict=False)[0]
video = VideoProcessor(vae_scale_factor=config.scale_factor_spatial).postprocess_video(video, output_type="np")
return video # [B, T, H, W, 3]
def decode_latents_to_video(
latents_np: np.ndarray,
output_path: Path,
*,
fps: int = 16,
backend: DecodeBackend = "taehv",
vae_dir: Path | None = None,
z_dim: int | None = None,
taehv_checkpoint: Path | None = None,
torch_device: str = "auto",
) -> dict[str, Any]:
"""Decode latent video frames and export them as an MP4 file.
Parameters:
latents_np (np.ndarray): Latent video representation to decode.
output_path (Path): Destination path for the MP4 file.
fps (int): Output video frame rate.
backend (DecodeBackend): Decoder backend to use.
vae_dir (Path | None): Directory containing the full Wan VAE when using
the ``wan-vae`` backend.
z_dim (int | None): Latent channel count for TAEHV decoding.
taehv_checkpoint (Path | None): Optional TAEHV checkpoint path.
torch_device (str): PyTorch device selection for PyTorch-based decoding.
Returns:
dict[str, Any]: Decode time in seconds, backend name, output path, frame
count, and video resolution.
Raises:
ValueError: If the full VAE backend lacks ``vae_dir`` or the backend is
unknown.
"""
import time
from diffusers.utils import export_to_video
t0 = time.perf_counter()
if backend in ("taehv", "taehv-torch"):
c = latents_np.shape[1] if z_dim is None else z_dim
if backend == "taehv":
video = decode_latents_taehv_mlx(latents_np, z_dim=c, checkpoint_path=taehv_checkpoint)
else:
# torch TAEHV (regression / parity reference)
import torch
from fastvideo.third_party.taehv import TAEHV
ckpt = ensure_taehv_checkpoint(z_dim=c, checkpoint_path=taehv_checkpoint)
if torch_device == "auto":
torch_device = "mps" if torch.backends.mps.is_available() else "cpu"
dtype = torch.float16 if torch_device == "mps" else torch.float32
model = TAEHV(str(ckpt)).to(device=torch_device, dtype=dtype).eval()
lat = torch.from_numpy(latents_np).to(device=torch_device, dtype=dtype)
with torch.no_grad():
out = model.decode_video(lat.transpose(1, 2), parallel=True, show_progress_bar=False)
video = out[0].permute(0, 2, 3, 1).float().cpu().numpy()[None, ...]
# out is NTCHW -> need BTHWC; decode_video returns NTCHW for batch
if video.ndim == 4:
video = video[None]
elif backend == "wan-vae":
if vae_dir is None:
raise ValueError("vae_dir required for wan-vae backend")
video = decode_latents_wan_vae_torch(latents_np, vae_dir=vae_dir, device=torch_device)
else:
raise ValueError(f"unknown backend {backend}")
decode_s = time.perf_counter() - t0
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
# export_to_video expects list/array of frames HxWxC
frames = video[0]
frames = np.clip(frames, 0.0, 1.0)
export_to_video(frames, str(output_path), fps=fps)
if backend == "taehv":
cleanup_mlx()
else:
if backend == "taehv-torch":
del model, lat, out
cleanup_torch_mps()
return {
"decode_s": decode_s,
"backend": backend,
"output_path": str(output_path),
"num_frames": int(frames.shape[0]),
"resolution": f"{frames.shape[2]}x{frames.shape[1]}" if frames.ndim == 4 else None,
}
+223
View File
@@ -0,0 +1,223 @@
# SPDX-License-Identifier: Apache-2.0
"""Chunked non-causal sliding-window self-attention for MLX scaling studies.
This module is intentionally standalone (``mlx.core`` + stdlib only) so it can
be micro-benchmarked without pulling in the DiT / FastVideo stack.
Window policy
-------------
**Symmetric** sliding window (non-causal). For query index ``i`` the allowed
key indices are:
sinks: ``j in [0, sink)`` (always visible to every query, if ``sink > 0``)
local: ``j in [max(0, i - half), min(S, i + half + 1))``
where ``half = window // 2``
so each query sees roughly ``window + 1`` local keys (plus any sinks outside
that range). This is appropriate for a dense, bidirectional DiT denoise pass.
Implementation note (FLOPs)
---------------------------
A full-size additive attention mask still materialises an ``O(S^2)`` score
matrix inside SDPA and does **not** reduce work. Instead we tile the sequence
into query blocks and run ``mx.fast.scaled_dot_product_attention`` only against
the union of keys that block needs (local slice ± sinks). That makes
per-block work ``O(chunk * (window + sink) * D)`` and total work
``O(S * (window + sink) * D)``.
"""
from __future__ import annotations
import mlx.core as mx
def _default_scale(head_dim: int, scale: float | None) -> float:
"""
Determine the attention scaling factor from an explicit value or head dimension.
Parameters:
head_dim (int): The attention head dimension used to derive the default scale.
scale (Optional[float]): An explicit scaling factor.
Returns:
float: The explicit scale converted to a float, or the reciprocal square root of `head_dim`.
Raises:
ValueError: If `scale` is not provided and `head_dim` is not positive.
"""
if scale is not None:
return float(scale)
if head_dim <= 0:
raise ValueError(f"head_dim must be positive, got {head_dim}")
return head_dim**-0.5
def _validate_qkv(q: mx.array, k: mx.array, v: mx.array) -> tuple[int, int, int, int]:
"""
Validate compatible rank-4 query, key, and value tensors.
Parameters:
q (mx.array): Query tensor shaped `(B, H, S, D)`.
k (mx.array): Key tensor with the same shape as `q`.
v (mx.array): Value tensor with the same shape as `q`.
Returns:
tuple[int, int, int, int]: Batch size, head count, sequence length, and head dimension.
Raises:
ValueError: If the tensors are not rank 4, do not have identical shapes, or have an empty sequence or head dimension.
"""
if q.ndim != 4 or k.ndim != 4 or v.ndim != 4:
raise ValueError(f"q/k/v must be rank-4 (B, H, S, D); got shapes "
f"{q.shape}, {k.shape}, {v.shape}")
b, h, s, d = q.shape
if k.shape != (b, h, s, d) or v.shape != (b, h, s, d):
raise ValueError(f"q/k/v shapes must match exactly; got q={q.shape}, k={k.shape}, v={v.shape}")
if s == 0:
raise ValueError("sequence length S must be > 0")
if d == 0:
raise ValueError("head dim D must be > 0")
return b, h, s, d
def full_attention(
q: mx.array,
k: mx.array,
v: mx.array,
scale: float | None = None,
) -> mx.array:
"""
Compute dense scaled dot-product attention over the full sequence.
Parameters:
scale (float, optional): Attention scaling factor. If omitted, uses the
inverse square root of the head dimension.
Returns:
mx.array: Attention output with shape ``(B, H, S, D)``.
"""
_, _, _, d = _validate_qkv(q, k, v)
sc = _default_scale(d, scale)
return mx.fast.scaled_dot_product_attention(q, k, v, scale=sc)
def _concat_kv_slices(
k: mx.array,
v: mx.array,
ranges: list[tuple[int, int]],
) -> tuple[mx.array, mx.array]:
"""Concatenate non-overlapping ``[start, end)`` key/value slices along seq."""
if not ranges:
raise ValueError("ranges must be non-empty")
if len(ranges) == 1:
s0, e0 = ranges[0]
return k[:, :, s0:e0, :], v[:, :, s0:e0, :]
k_parts = [k[:, :, s:e, :] for s, e in ranges]
v_parts = [v[:, :, s:e, :] for s, e in ranges]
return mx.concatenate(k_parts, axis=2), mx.concatenate(v_parts, axis=2)
def _key_ranges_for_block(
qs: int,
qe: int,
seq_len: int,
half: int,
sink: int,
) -> list[tuple[int, int]]:
"""Return ordered, non-overlapping key ranges for a query block ``[qs, qe)``.
Symmetric local window over every query in the block, plus global sinks
``[0, sink)``. Overlap is merged into a single contiguous range when
possible so we avoid double-counting sink tokens.
"""
local_start = max(0, qs - half)
# Last query index is ``qe - 1``; its right edge is ``qe - 1 + half + 1 = qe + half``.
local_end = min(seq_len, qe + half)
if local_start >= local_end:
# Degenerate (should not happen for valid qs < qe); fall back to sinks only.
if sink > 0:
return [(0, min(sink, seq_len))]
raise ValueError(f"empty local key range for query block [{qs}, {qe})")
if sink <= 0:
return [(local_start, local_end)]
sink_end = min(sink, seq_len)
if local_start <= sink_end:
# Sinks abut or overlap the local window — one contiguous slice from 0.
return [(0, max(local_end, sink_end))]
# Gap between sinks and local window: two slices, concat at SDPA time.
return [(0, sink_end), (local_start, local_end)]
def windowed_attention(
q: mx.array,
k: mx.array,
v: mx.array,
window: int,
sink: int = 0,
scale: float | None = None,
*,
chunk_size: int | None = None,
) -> mx.array:
"""
Apply symmetric sliding-window self-attention with optional global sink positions.
Parameters:
q (mx.array): Query tensor shaped `(B, H, S, D)`.
k (mx.array): Key tensor shaped `(B, H, S, D)`.
v (mx.array): Value tensor shaped `(B, H, S, D)`.
window (int): Symmetric attention window width in tokens; must be at least 1.
sink (int): Number of leading key positions available to every query; must
be between 0 and the sequence length.
scale (Optional[float]): Softmax scale. Defaults to `1 / sqrt(D)`.
chunk_size (Optional[int]): Query block length used for chunked processing.
Defaults to the smaller of `window` and 512.
Returns:
mx.array: Attention output with the same shape as `q`.
Raises:
ValueError: If the inputs or attention parameters are invalid.
RuntimeError: If a query block has no available keys.
"""
_, _, seq_len, d = _validate_qkv(q, k, v)
if window < 1:
raise ValueError(f"window must be >= 1, got {window}")
if sink < 0:
raise ValueError(f"sink must be >= 0, got {sink}")
if sink > seq_len:
raise ValueError(f"sink ({sink}) cannot exceed sequence length ({seq_len})")
sc = _default_scale(d, scale)
half = window // 2
# When the requested window is at least the sequence length, every query can
# see every key under a symmetric policy — fall back to one dense SDPA.
# (Sinks are redundant once the full key set is used.)
if window >= seq_len:
return mx.fast.scaled_dot_product_attention(q, k, v, scale=sc)
chunk = min(window, 512) if chunk_size is None else int(chunk_size)
if chunk < 1:
raise ValueError(f"chunk_size must be >= 1, got {chunk}")
chunk = min(chunk, seq_len)
outputs: list[mx.array] = []
for qs in range(0, seq_len, chunk):
qe = min(seq_len, qs + chunk)
q_block = q[:, :, qs:qe, :]
ranges = _key_ranges_for_block(qs, qe, seq_len, half, sink)
k_block, v_block = _concat_kv_slices(k, v, ranges)
if k_block.shape[2] == 0:
raise RuntimeError(f"empty key set for query block [{qs}, {qe}) with window={window}, sink={sink}")
query_positions = mx.arange(qs, qe)[:, None]
key_positions = mx.array([position for start, end in ranges for position in range(start, end)])[None, :]
mask = mx.abs(query_positions - key_positions) <= half
if sink > 0:
mask = mask | (key_positions < sink)
out_block = mx.fast.scaled_dot_product_attention(q_block, k_block, v_block, scale=sc, mask=mask)
outputs.append(out_block)
return mx.concatenate(outputs, axis=2)
+4 -1
View File
@@ -48,7 +48,10 @@ class MpsPlatform(Platform):
@classmethod
def get_attn_backend_cls(cls, selected_backend: AttentionBackendEnum | None, head_size: int,
dtype: torch.dtype) -> str:
# MPS supports SDPA (Scaled Dot-Product Attention) which is the most compatible
if selected_backend == AttentionBackendEnum.VIDEO_SPARSE_ATTN:
raise NotImplementedError("VIDEO_SPARSE_ATTN is not supported on MPS. Unset "
"FASTVIDEO_ATTENTION_BACKEND or set it to TORCH_SDPA.")
# MPS supports SDPA (Scaled Dot-Product Attention) which is the most compatible.
logger.info("Using Torch SDPA backend for MPS.")
return "fastvideo.attention.backends.sdpa.SDPABackend"
@@ -0,0 +1,110 @@
# SPDX-License-Identifier: Apache-2.0
"""GPU backward checks for the VSA-H3 backend.
The CuTe backend returns FA4's own output tensor, which FA4's autograd node
saved for its backward. Composing the compression branch onto it in place
therefore poisons the graph, and the failure only appears once the VSA-256
CuTe path has a backward at all. These tests pin the composition.
"""
import pytest
import torch
from fastvideo.attention.backends.video_sparse_attn_h3 import (MiniMaxH3VSAImpl, MiniMaxH3VSAMetadataBuilder)
_SPEC = dict(raw_latent_shape=(16, 16, 24), patch_size=(1, 2, 2), prefix_segments=(64, 32, 16))
_HEADS = 2
_DIM = 128
def _build_meta(device, sparsity=0.5):
return MiniMaxH3VSAMetadataBuilder().build(
current_timestep=0,
raw_latent_shape=_SPEC["raw_latent_shape"],
patch_size=_SPEC["patch_size"],
VSA_sparsity=sparsity,
prefix_segments=_SPEC["prefix_segments"],
device=device,
)
def _select_backend(monkeypatch, backend):
if backend == "cute":
pytest.importorskip(
"flash_attn.cute.block_sparsity",
reason="optional FA4 CuTe build (flash_attn.cute) not installed",
)
monkeypatch.setenv("FASTVIDEO_VSA_CUTEDSL", "1")
monkeypatch.delenv("FASTVIDEO_VSA_TRITON", raising=False)
monkeypatch.delenv("FASTVIDEO_KERNEL_VSA_FORCE_TRITON", raising=False)
else:
monkeypatch.setenv("FASTVIDEO_VSA_TRITON", "1")
monkeypatch.delenv("FASTVIDEO_VSA_CUTEDSL", raising=False)
def _forward_backward(impl, meta, gate_compress, device):
seq = meta.total_seq_length
torch.manual_seed(0)
q, k, v = (torch.randn(1, seq, _HEADS, _DIM, device=device, dtype=torch.bfloat16, requires_grad=True)
for _ in range(3))
tq, tk, tv = (impl.tile(t, meta).clone() for t in (q, k, v))
gate = None
if gate_compress:
gate = torch.randn(1, tq.shape[1], _HEADS, _DIM, device=device, dtype=torch.bfloat16) * 0.1
out = impl.forward(tq, tk, tv, gate, meta)
out = impl.postprocess_output(out, meta)
out.float().pow(2).sum().backward()
return out, (q, k, v)
@pytest.mark.parametrize("backend", ["triton", "cute"])
@pytest.mark.parametrize("gate_compress", [False, True])
def test_h3_vsa_backward_runs(monkeypatch, backend: str, gate_compress: bool) -> None:
"""Regression: with the CuTe backend and a non-zero gate this used to die
with "one of the variables needed for gradient computation has been
modified by an inplace operation ... output 0 of FlashAttnFuncBackward".
"""
if not torch.cuda.is_available():
pytest.skip("CUDA is required")
_select_backend(monkeypatch, backend)
device = torch.device("cuda")
meta = _build_meta(device)
impl = MiniMaxH3VSAImpl(num_heads=_HEADS, head_size=_DIM, causal=False, softmax_scale=_DIM**-0.5)
out, leaves = _forward_backward(impl, meta, gate_compress, device)
assert torch.isfinite(out).all().item()
for name, leaf in zip(("q", "k", "v"), leaves):
assert leaf.grad is not None, f"{name} received no gradient"
assert torch.isfinite(leaf.grad).all().item(), f"{name}.grad has non-finite values"
assert leaf.grad.abs().sum().item() > 0, f"{name}.grad is all zero"
@pytest.mark.parametrize("gate_compress", [False, True])
def test_h3_vsa_backward_cute_matches_triton(monkeypatch, gate_compress: bool) -> None:
"""CuTe and Triton take different routes to the same math; their gradients
should agree to bf16 tolerance."""
if not torch.cuda.is_available():
pytest.skip("CUDA is required")
device = torch.device("cuda")
impl = MiniMaxH3VSAImpl(num_heads=_HEADS, head_size=_DIM, causal=False, softmax_scale=_DIM**-0.5)
grads = {}
for backend in ("triton", "cute"):
with monkeypatch.context() as m:
_select_backend(m, backend)
meta = _build_meta(device)
_, leaves = _forward_backward(impl, meta, gate_compress, device)
grads[backend] = [leaf.grad.detach().float() for leaf in leaves]
for name, ref, got in zip(("dq", "dk", "dv"), grads["triton"], grads["cute"]):
diff = (ref - got).abs()
avg_abs = diff.mean().item()
max_rel = (diff.max() / (ref.abs().mean() + 1e-6)).item()
print(f"[h3-vsa gate={gate_compress}] {name}: avg_abs={avg_abs:.6e}, max_rel={max_rel:.6e}")
assert avg_abs < 1e-2, f"{name}: avg_abs {avg_abs:.3e}"
assert max_rel < 0.5, f"{name}: max_rel {max_rel:.3e}"
@@ -2,8 +2,10 @@ import os
from types import SimpleNamespace
import warnings
import numpy as np
import pytest
import torch
from einops import rearrange
import fastvideo.entrypoints.video_generator as video_generator_module
from fastvideo.api import (
@@ -271,6 +273,70 @@ def test_generate_single_video_return_frames_still_materializes_output(tmp_path)
assert result["video_path"] is None
def test_generate_single_video_frames_match_legacy_cpu_loop(tmp_path):
"""The on-device quantize path (#1362) must reproduce the legacy
per-frame CPU loop (make_grid -> permute -> *255 -> uint8) bit-exactly
for in-range fp32 pixels: same uint8 dtype, same HWC grid layout with
nrow=6 (batch>1), odd frame count. CPU-only: on CUDA the float->uint8
cast may differ by <=1 LSB, but on CPU both orderings run identical
fp32 ops, so exact equality is required."""
torch.manual_seed(0)
output = torch.rand((2, 3, 3, 16, 16), dtype=torch.float32)
output_batch = _single_video_output_batch(output)
fastvideo_args = _single_video_args()
generator = _single_video_generator(output_batch, fastvideo_args)
sampling_param = _small_sampling_param(save_video=False, return_frames=True)
sampling_param.num_frames = 3
sampling_param.num_videos_per_prompt = 2
result = generator._generate_single_video(
prompt="grid parity",
sampling_param=sampling_param,
fastvideo_args=fastvideo_args,
output_path=str(tmp_path / "unused.mp4"),
)
legacy_frames = []
for x in rearrange(output, "b c t h w -> t b c h w"):
grid = video_generator_module.torchvision.utils.make_grid(x, nrow=6)
grid = grid.permute(1, 2, 0).squeeze(-1)
legacy_frames.append((grid * 255).to(torch.uint8).contiguous().cpu().numpy())
torch.testing.assert_close(result["samples"], output)
assert len(result["frames"]) == 3
for got, want in zip(result["frames"], legacy_frames, strict=True):
assert got.dtype == np.uint8
assert got.shape == want.shape
np.testing.assert_array_equal(got, want)
def test_generate_single_video_frames_clamp_out_of_range_pixels(tmp_path):
"""VAE output slightly outside [0, 1] must saturate at 0/255 in the
uint8 frames. The pre-#1362 unclamped cast wrapped mod 256 (e.g.
1.5 -> 126). CPU-only."""
output = torch.full((1, 3, 2, 16, 16), 1.5, dtype=torch.float32)
output[:, :, 1] = -0.5
output_batch = _single_video_output_batch(output)
fastvideo_args = _single_video_args()
generator = _single_video_generator(output_batch, fastvideo_args)
result = generator._generate_single_video(
prompt="clamp",
sampling_param=_small_sampling_param(save_video=False, return_frames=True),
fastvideo_args=fastvideo_args,
output_path=str(tmp_path / "unused.mp4"),
)
frames = result["frames"]
assert len(frames) == 2
# make_grid passes a single image through without grid padding, so
# every pixel comes from the (clamped) output tensor.
assert frames[0].dtype == np.uint8
assert frames[0].shape == (16, 16, 3)
assert (frames[0] == 255).all()
assert (frames[1] == 0).all()
def test_generate_single_video_save_video_still_builds_frames(monkeypatch, tmp_path):
output = torch.ones((1, 3, 2, 16, 16), dtype=torch.float32) * 0.5
output_batch = _single_video_output_batch(output)
@@ -305,6 +371,37 @@ def test_generate_single_video_save_video_still_builds_frames(monkeypatch, tmp_p
}
def test_generate_single_video_save_only_reports_refined_output_size(monkeypatch, tmp_path):
"""`GenerationResult.size` must describe the decoded media even when the
fp32 `samples` mirror is skipped (`return_frames=False`, the CLI save
flow). Refiner pipelines can change the final pixel geometry, so the size
has to come from `output_batch.output`, not the base request. CPU-only."""
# Refiner-style output: request asks for 2 frames of 16x16, pipeline
# produces 5 frames of 32x48.
output = torch.full((1, 3, 5, 32, 48), 0.5, dtype=torch.float32)
output_batch = _single_video_output_batch(output)
fastvideo_args = _single_video_args()
generator = _single_video_generator(output_batch, fastvideo_args)
saved = {}
def fake_mimsave(path, frames, *, fps, format):
saved["frame_count"] = len(frames)
monkeypatch.setattr(video_generator_module.imageio, "mimsave", fake_mimsave)
result = generator._generate_single_video(
prompt="refined save",
sampling_param=_small_sampling_param(save_video=True, return_frames=False),
fastvideo_args=fastvideo_args,
output_path=str(tmp_path / "refined.mp4"),
)
assert result["samples"] is None
assert result["frames"] is None
assert result["size"] == (32, 48, 5)
assert saved["frame_count"] == 5
def test_generate_single_video_audio_only_metadata_returns_audio_without_frames(tmp_path):
audio = torch.zeros((16, ), dtype=torch.float32)
output_batch = _single_video_output_batch(
+124
View File
@@ -0,0 +1,124 @@
# SPDX-License-Identifier: Apache-2.0
"""CPU-only correctness contract for the on-device MLX DMD sampler.
The sampler's arithmetic uses only ``+``/``-``/``*``, so NumPy arrays are a
valid stand-in for MLX arrays here: these tests validate the *math* against the
torch reference formulas without requiring an MLX/Metal device.
"""
import numpy as np
from fastvideo.mlx_runtime.sampling import (
MLXDMDSchedule,
add_noise,
dmd_step,
pred_noise_to_pred_video,
)
def _reference_schedule() -> MLXDMDSchedule:
# A small monotonic flow-match-like schedule: timesteps 0..999, sigmas in
# (0, 1]. Exact values are irrelevant; the lookup must pick the nearest.
"""Create the deterministic schedule used by the reference tests.
Returns:
MLXDMDSchedule: A schedule with timesteps from 0 through 999 and linearly decreasing sigma values from 1.0 to 0.001.
"""
timesteps = np.arange(1000, dtype=np.float64)
sigmas = np.linspace(1.0, 1e-3, 1000, dtype=np.float64)
return MLXDMDSchedule(sigmas=sigmas, timesteps=timesteps)
def test_sigma_for_picks_nearest_timestep() -> None:
schedule = _reference_schedule()
# Exact hits.
assert schedule.sigma_for(0) == schedule.sigmas[0]
assert schedule.sigma_for(999) == schedule.sigmas[999]
# Nearest-neighbour rounding for a value between grid points.
assert schedule.sigma_for(522.4) == schedule.sigmas[522]
assert schedule.sigma_for(521.6) == schedule.sigmas[522]
def test_pred_noise_to_pred_video_matches_reference_formula() -> None:
rng = np.random.default_rng(0)
pred_noise = rng.standard_normal((1, 16, 3, 8, 8)).astype(np.float32)
noise_input = rng.standard_normal((1, 16, 3, 8, 8)).astype(np.float32)
sigma = 0.37
got = pred_noise_to_pred_video(pred_noise, noise_input, sigma)
expected = noise_input - sigma * pred_noise
np.testing.assert_allclose(got, expected, rtol=0, atol=0)
def test_add_noise_matches_reference_formula() -> None:
rng = np.random.default_rng(1)
clean = rng.standard_normal((1, 16, 3, 8, 8)).astype(np.float32)
noise = rng.standard_normal((1, 16, 3, 8, 8)).astype(np.float32)
sigma = 0.62
got = add_noise(clean, noise, sigma)
expected = (1.0 - sigma) * clean + sigma * noise
np.testing.assert_allclose(got, expected, rtol=0, atol=0)
def test_dmd_step_final_returns_clean_prediction() -> None:
schedule = _reference_schedule()
rng = np.random.default_rng(2)
pred_noise = rng.standard_normal((1, 16, 3, 8, 8)).astype(np.float32)
noise_input = rng.standard_normal((1, 16, 3, 8, 8)).astype(np.float32)
out = dmd_step(
latents=noise_input,
noise_input_latent=noise_input,
pred_noise=pred_noise,
schedule=schedule,
timestep=522,
next_timestep=None,
)
sigma = schedule.sigma_for(522)
np.testing.assert_allclose(out, noise_input - sigma * pred_noise, rtol=0, atol=0)
def test_dmd_step_intermediate_renoises_to_next_level() -> None:
"""
Verifies that an intermediate DMD step re-noises the denoised prediction at the next schedule level.
"""
schedule = _reference_schedule()
rng = np.random.default_rng(3)
pred_noise = rng.standard_normal((1, 16, 3, 8, 8)).astype(np.float32)
noise_input = rng.standard_normal((1, 16, 3, 8, 8)).astype(np.float32)
renoise = rng.standard_normal((1, 16, 3, 8, 8)).astype(np.float32)
out = dmd_step(
latents=noise_input,
noise_input_latent=noise_input,
pred_noise=pred_noise,
schedule=schedule,
timestep=1000,
next_timestep=757,
noise=renoise,
)
sigma = schedule.sigma_for(1000)
sigma_next = schedule.sigma_for(757)
pred_video = noise_input - sigma * pred_noise
expected = (1.0 - sigma_next) * pred_video + sigma_next * renoise
np.testing.assert_allclose(out, expected, rtol=0, atol=0)
def test_dmd_step_requires_noise_for_intermediate_step() -> None:
"""Verify that an intermediate DMD step requires a noise tensor for re-noising."""
schedule = _reference_schedule()
zeros = np.zeros((1, 16, 3, 8, 8), dtype=np.float32)
try:
dmd_step(
latents=zeros,
noise_input_latent=zeros,
pred_noise=zeros,
schedule=schedule,
timestep=1000,
next_timestep=757,
noise=None,
)
except ValueError:
return
raise AssertionError("dmd_step must raise when re-noise is requested without noise")
@@ -0,0 +1,99 @@
# SPDX-License-Identifier: Apache-2.0
"""CPU-only contracts for pixel-space frame resampling."""
from __future__ import annotations
import numpy as np
import pytest
from fastvideo.mlx_runtime.frame_upsample import (
PIXEL_UPSAMPLE_MODES,
unsharp,
upsample_frame,
upsample_frames,
)
from fastvideo.mlx_runtime.rife_interp import aligned_keyframe_count
def _frame(height: int = 24, width: int = 40, seed: int = 0) -> np.ndarray:
rng = np.random.default_rng(seed)
return rng.integers(0, 256, (height, width, 3), dtype=np.uint8)
@pytest.mark.parametrize("mode", PIXEL_UPSAMPLE_MODES)
def test_upsample_frame_hits_target_size_in_every_mode(mode: str) -> None:
out = upsample_frame(_frame(), width=80, height=48, mode=mode)
assert out.shape == (48, 80, 3)
assert out.dtype == np.uint8
def test_upsample_frame_rejects_unknown_mode() -> None:
with pytest.raises(ValueError, match="Unsupported pixel upsample mode"):
upsample_frame(_frame(), width=80, height=48, mode="mitchell")
def test_upsample_frame_rejects_bad_shape() -> None:
with pytest.raises(ValueError, match="HxWx3"):
upsample_frame(np.zeros((24, 40), dtype=np.uint8), width=80, height=48)
def test_upsample_frame_rejects_nonpositive_size() -> None:
with pytest.raises(ValueError, match="must be positive"):
upsample_frame(_frame(), width=0, height=48)
def test_upsample_frame_at_target_size_is_a_passthrough() -> None:
frame = _frame()
np.testing.assert_array_equal(upsample_frame(frame, width=40, height=24), frame)
def test_nearest_upsample_is_exact_block_replication() -> None:
"""A 2x nearest resize must duplicate pixels, not blend them."""
frame = _frame(height=4, width=6)
out = upsample_frame(frame, width=12, height=8, mode="nearest")
np.testing.assert_array_equal(out, np.repeat(np.repeat(frame, 2, axis=0), 2, axis=1))
def test_unsharp_zero_amount_is_identity() -> None:
frame = _frame()
assert unsharp(frame, 0.0) is frame
def test_unsharp_steepens_a_soft_edge() -> None:
# A blurred step edge: unsharp masking should raise its peak gradient.
import cv2
frame = np.zeros((24, 40, 3), dtype=np.uint8)
frame[:, 20:] = 200
frame = cv2.GaussianBlur(frame, (0, 0), 2.0)
def peak_gradient(image: np.ndarray) -> float:
return float(np.abs(np.diff(image[:, :, 0].astype(np.int32), axis=1)).max())
assert peak_gradient(unsharp(frame, 0.8)) > peak_gradient(frame)
def test_upsample_frames_preserves_order_and_leaves_input_alone() -> None:
frames = [_frame(seed=index) for index in range(3)]
originals = [frame.copy() for frame in frames]
out = upsample_frames(frames, width=80, height=48)
assert len(out) == 3
assert all(frame.shape == (48, 80, 3) for frame in out)
# Distinct inputs stay distinct and in order.
assert not np.array_equal(out[0], out[1])
for frame, original in zip(frames, originals, strict=True):
np.testing.assert_array_equal(frame, original)
@pytest.mark.parametrize(
("target_frames", "factor", "keyframes"),
[(1, 999, 1), (81, 2, 41), (81, 3, 29), (82, 2, 45), (121, 4, 33)],
)
def test_aligned_keyframes_expand_to_exact_target(target_frames: int, factor: int, keyframes: int) -> None:
assert aligned_keyframe_count(target_frames, factor) == keyframes
interpolated_frames = (keyframes - 1) * factor + 1
assert interpolated_frames >= target_frames
assert keyframes % 4 == 1
if keyframes > 1:
assert (keyframes - 5) * factor + 1 < target_frames
@@ -0,0 +1,30 @@
# SPDX-License-Identifier: Apache-2.0
"""Import hygiene for the lightweight Apple Silicon MLX test path."""
from __future__ import annotations
import subprocess
import sys
_HEAVY = "fastvideo.entrypoints.video_generator"
def _modules_after(import_line: str) -> set[str]:
"""Return sys.modules after running one import in a fresh interpreter."""
code = f"import sys; {import_line}; print('\\n'.join(sorted(sys.modules)))"
result = subprocess.run([sys.executable, "-c", code], check=True, capture_output=True, text=True)
return set(result.stdout.split())
def test_mlx_runtime_import_adds_no_heavy_entrypoints() -> None:
"""Importing the MLX runtime must not drag in the generator stack itself.
``fastvideo/__init__.py`` imports ``VideoGenerator`` eagerly, so every
``fastvideo.*`` import loads it transitively. That is a property of the
top-level package rather than of this runtime, so compare against a plain
``import fastvideo`` baseline and assert the MLX runtime adds nothing heavy
on top of it.
"""
baseline = _modules_after("import fastvideo")
with_runtime = _modules_after("import fastvideo.mlx_runtime.memory")
assert _HEAVY not in with_runtime - baseline
+100
View File
@@ -0,0 +1,100 @@
# SPDX-License-Identifier: Apache-2.0
import os
import pytest
from fastvideo.mlx_runtime.memory import add_memory_limit_args, apply_memory_limits, gib_to_bytes
class _FakeMLX:
def __init__(self) -> None:
self.calls: list[tuple[str, int]] = []
def set_memory_limit(self, value: int) -> int:
"""
Record a requested memory limit and provide the prior limit.
Parameters:
value (int): The memory limit to record.
Returns:
int: The previously configured memory limit.
"""
self.calls.append(("memory", value))
return 111
def set_cache_limit(self, value: int) -> int:
"""Record and apply a cache memory limit.
Parameters:
value (int): Cache memory limit in bytes.
Returns:
int: The previously configured cache memory limit.
"""
self.calls.append(("cache", value))
return 222
def set_wired_limit(self, value: int) -> int:
self.calls.append(("wired", value))
return 333
def test_gib_to_bytes_rejects_non_positive_values() -> None:
assert gib_to_bytes(None) is None
assert gib_to_bytes(1.5) == int(1.5 * 1024**3)
with pytest.raises(ValueError, match="positive"):
gib_to_bytes(0)
def test_apply_memory_limits_sets_mlx_limits_and_metrics(monkeypatch) -> None:
monkeypatch.delenv("PYTORCH_MPS_HIGH_WATERMARK_RATIO", raising=False)
monkeypatch.delenv("PYTORCH_MPS_LOW_WATERMARK_RATIO", raising=False)
fake_mlx = _FakeMLX()
applied = apply_memory_limits(
mlx_memory_limit_gib=16,
mlx_disable_cache=True,
mlx_wired_limit_gib=12,
torch_mps_high_watermark_ratio=0.57,
mx_module=fake_mlx,
)
assert os.environ["PYTORCH_MPS_HIGH_WATERMARK_RATIO"] == "0.57"
assert os.environ["PYTORCH_MPS_LOW_WATERMARK_RATIO"] == "0.0"
assert fake_mlx.calls == [
("memory", 16 * 1024**3),
("cache", 0),
("wired", 12 * 1024**3),
]
metrics = applied.as_metrics()
assert metrics["mlx_memory_limit_bytes"] == 16 * 1024**3
assert metrics["mlx_cache_limit_bytes"] == 0
assert metrics["previous_mlx_memory_limit_bytes"] == 111
assert metrics["previous_mlx_cache_limit_bytes"] == 222
assert metrics["previous_mlx_wired_limit_bytes"] == 333
def test_apply_memory_limits_validates_watermarks() -> None:
with pytest.raises(ValueError, match="non-negative"):
apply_memory_limits(torch_mps_high_watermark_ratio=-1)
def test_add_memory_limit_args_uses_defaults() -> None:
import argparse
parser = argparse.ArgumentParser()
add_memory_limit_args(
parser,
mlx_memory_limit_gib=16,
mlx_disable_cache=True,
torch_mps_high_watermark_ratio=0.57,
torch_mps_low_watermark_ratio=0.0,
)
args = parser.parse_args([])
assert args.mlx_memory_limit_gib == 16
assert args.mlx_disable_cache is True
assert args.torch_mps_high_watermark_ratio == 0.57
assert args.torch_mps_low_watermark_ratio == 0.0
+123
View File
@@ -0,0 +1,123 @@
# SPDX-License-Identifier: Apache-2.0
"""Round-trip tests for pre-quantized MLX DiT checkpoints.
Saving an already-quantized DiT and reloading it must reproduce the original
forward pass exactly (same packed weights, scales, and config), for both plain
fp32 and int8-quantized models. Runs on any MLX backend (Metal or CPU).
"""
from __future__ import annotations
import json
import numpy as np
import pytest
pytest.importorskip("mlx.core", reason="MLX is required for checkpoint tests")
from fastvideo.mlx_runtime.checkpoint import ( # noqa: E402
MANIFEST_FILENAME,
load_mlx_dit_checkpoint,
save_mlx_dit_checkpoint,
)
from fastvideo.mlx_runtime.fastwan import ( # noqa: E402
MLXQuantizationSpec,
QuantizedMatrix,
)
from fastvideo.tests.mlx.tiny_wan import ( # noqa: E402
build_hf_config,
build_inputs,
build_tiny_wan_config,
build_torch_model,
mlx_dit_from_torch_model,
mlx_output,
mlx_rotary_embeddings,
)
def _forward(dit) -> np.ndarray:
"""Run the DiT model with standard inputs and return its output as a NumPy array."""
hidden_states, encoder_hidden_states, timestep = build_inputs()
return mlx_output(dit, hidden_states, encoder_hidden_states, timestep, mlx_rotary_embeddings(hidden_states))
def test_plain_checkpoint_round_trip(distributed_setup, tmp_path) -> None:
dit = mlx_dit_from_torch_model(build_torch_model(), build_hf_config(build_tiny_wan_config()))
before = _forward(dit)
save_mlx_dit_checkpoint(dit, tmp_path / "ckpt")
loaded = load_mlx_dit_checkpoint(tmp_path / "ckpt")
# JSON canonicalizes tuples to lists (matching what the Diffusers
# config.json loader produces), so compare JSON-normalized configs.
assert json.loads(json.dumps(loaded.config)) == json.loads(json.dumps(dit.config))
assert len(loaded.blocks) == len(dit.blocks)
after = _forward(loaded)
np.testing.assert_array_equal(before, after)
def test_int8_checkpoint_round_trip_skips_requantization(distributed_setup, tmp_path) -> None:
spec = MLXQuantizationSpec.from_name("int8")
dit = mlx_dit_from_torch_model(build_torch_model(), build_hf_config(build_tiny_wan_config()),
quantization=spec)
before = _forward(dit)
save_mlx_dit_checkpoint(dit, tmp_path / "ckpt-int8")
loaded = load_mlx_dit_checkpoint(tmp_path / "ckpt-int8")
# The loaded model must carry the same packed quantized weights -- no
# requantization pass -- and produce a bitwise-identical forward.
reloaded_q = loaded.weights["proj_out.weight"]
original_q = dit.weights["proj_out.weight"]
assert isinstance(reloaded_q, QuantizedMatrix)
assert reloaded_q.spec == spec
np.testing.assert_array_equal(np.array(reloaded_q.weight), np.array(original_q.weight))
np.testing.assert_array_equal(np.array(reloaded_q.scales), np.array(original_q.scales))
block_q = loaded.blocks[0].weights["to_q.weight"]
assert isinstance(block_q, QuantizedMatrix)
after = _forward(loaded)
np.testing.assert_array_equal(before, after)
def test_missing_checkpoint_raises_clear_error(tmp_path) -> None:
with pytest.raises(FileNotFoundError, match="Not an MLX DiT checkpoint"):
load_mlx_dit_checkpoint(tmp_path / "does-not-exist")
def test_existing_checkpoint_is_replaced(distributed_setup, tmp_path) -> None:
dit = mlx_dit_from_torch_model(build_torch_model(), build_hf_config(build_tiny_wan_config()))
checkpoint_dir = save_mlx_dit_checkpoint(dit, tmp_path / "ckpt")
dit.config["checkpoint_revision"] = "second"
save_mlx_dit_checkpoint(dit, checkpoint_dir)
assert load_mlx_dit_checkpoint(checkpoint_dir).config["checkpoint_revision"] == "second"
def test_failed_checkpoint_overwrite_preserves_existing(distributed_setup, tmp_path) -> None:
dit = mlx_dit_from_torch_model(build_torch_model(), build_hf_config(build_tiny_wan_config()))
checkpoint_dir = save_mlx_dit_checkpoint(dit, tmp_path / "ckpt")
weights_before = (checkpoint_dir / "mlx_dit.safetensors").read_bytes()
manifest_before = (checkpoint_dir / MANIFEST_FILENAME).read_bytes()
dit.config["not_json_serializable"] = object()
with pytest.raises(TypeError, match="not JSON serializable"):
save_mlx_dit_checkpoint(dit, checkpoint_dir)
assert (checkpoint_dir / "mlx_dit.safetensors").read_bytes() == weights_before
assert (checkpoint_dir / MANIFEST_FILENAME).read_bytes() == manifest_before
def test_future_format_version_is_rejected(distributed_setup, tmp_path) -> None:
dit = mlx_dit_from_torch_model(build_torch_model(), build_hf_config(build_tiny_wan_config()))
ckpt = save_mlx_dit_checkpoint(dit, tmp_path / "ckpt")
manifest_path = ckpt / MANIFEST_FILENAME
manifest = json.loads(manifest_path.read_text())
manifest["format_version"] = 99
manifest_path.write_text(json.dumps(manifest))
with pytest.raises(ValueError, match="format_version=99"):
load_mlx_dit_checkpoint(ckpt)
@@ -0,0 +1,134 @@
# SPDX-License-Identifier: Apache-2.0
"""Guard the ``mx.compile`` path of the MLX FastWan forward.
`mx.compile` on the DiT forward was silently broken: a NumPy scalar multiplying
a traced array (``np.sqrt(2/pi) * x`` in ``gelu_tanh``) dispatched through NumPy,
which evals the traced array — illegal during compile. It raised "Attempting to
eval an array during function transformations" (caught → eager fallback, no
speedup) or segfaulted the process. These tests keep the compiled forward tracing
cleanly and numerically identical to eager, so the ~1.4x compile speedup cannot
silently regress. See ``fastvideo/mlx_runtime/fastwan.py`` and
``docs/design/apple_silicon_benchmark_baseline.md``.
"""
from __future__ import annotations
import numpy as np
import pytest
mx = pytest.importorskip("mlx.core", reason="MLX is required for the compile-parity tests")
from fastvideo.mlx_runtime.fastwan import ( # noqa: E402
MLXWanTransformerBlock,
gelu_tanh,
timestep_embedding,
)
# One module-level generator so successive _rand calls of the same shape get
# distinct values (re-seeding per call made every (dim, dim) weight identical).
_RNG = np.random.default_rng(0)
# Through MLX 0.31 the compiler emitted the same kernels as eager for these
# graphs, so bit-identity was the contract. MLX 0.32 fuses more aggressively and
# reassociates float ops, which moves a handful of elements by a few ULP (max
# observed: 2.4e-07 absolute, 6.4e-06 relative, on 41 of 7680 elements). That is
# reassociation, not a regression, so assert bit-identity where it is guaranteed
# and a tight tolerance otherwise. Either way a silent fallback to eager or a
# broken trace still fails the test.
_MLX_VERSION = tuple(int(part) for part in mx.__version__.split(".")[:2])
_COMPILE_IS_BITWISE = _MLX_VERSION < (0, 32)
def _assert_compile_parity(eager: "mx.array", compiled: "mx.array") -> None:
"""Compare an eager result against its compiled counterpart."""
if _COMPILE_IS_BITWISE:
np.testing.assert_array_equal(np.array(eager), np.array(compiled))
else:
np.testing.assert_allclose(np.array(eager), np.array(compiled), rtol=1e-5, atol=1e-6)
def _rand(*shape: int, scale: float = 1.0) -> "mx.array":
"""
Generate a float32 MLX array of normally distributed random values.
Parameters:
shape (int): Dimensions of the generated array.
scale (float): Factor applied to the sampled values.
Returns:
mx.array: The generated random array.
"""
return mx.array((_RNG.standard_normal(shape) * scale).astype(np.float32))
def test_gelu_tanh_compiles_and_matches_eager() -> None:
"""The tanh-GELU (the op that broke compile) traces and matches eager."""
x = _rand(1, 120, 64)
eager = gelu_tanh(x)
mx.eval(eager)
compiled = mx.compile(gelu_tanh)(x)
mx.eval(compiled)
_assert_compile_parity(eager, compiled)
def test_timestep_embedding_compiles_and_matches_eager() -> None:
"""``timestep_embedding`` (math.log fix sibling of gelu_tanh) compiles cleanly.
Unlike gelu_tanh, the exp/sin/cos chain can reassociate under compile, so
we allow a tight tolerance rather than bit-identity.
"""
t = mx.array(np.array([0.0, 250.0, 500.0, 1000.0], dtype=np.float32))
dim = 64
eager = timestep_embedding(t, dim)
mx.eval(eager)
compiled = mx.compile(lambda steps: timestep_embedding(steps, dim))(t)
mx.eval(compiled)
np.testing.assert_allclose(np.array(eager), np.array(compiled), rtol=1e-4, atol=1e-4)
def _tiny_block_weights(dim: int, ffn: int) -> dict:
"""
Create randomized weights for a small transformer block test fixture.
Parameters:
dim (int): Hidden dimension of the transformer block.
ffn (int): Intermediate dimension of the feed-forward network.
Returns:
dict: Randomized attention, normalization, modulation, and feed-forward weights and biases.
"""
square = ["to_q", "to_k", "to_v", "to_out", "attn2.to_q", "attn2.to_k", "attn2.to_v", "attn2.to_out"]
weights = {f"{k}.weight": _rand(dim, dim, scale=0.05) for k in square}
weights.update({f"{k}.bias": _rand(dim, scale=0.05) for k in ["to_q", "to_k", "to_v", "to_out"]})
weights.update({
"scale_shift_table": _rand(1, 6, dim, scale=0.05),
"norm_q.weight": _rand(dim, scale=0.05),
"norm_k.weight": _rand(dim, scale=0.05),
"self_attn_residual_norm.norm.weight": _rand(dim, scale=0.05),
"self_attn_residual_norm.norm.bias": _rand(dim, scale=0.05),
"attn2.norm_q.weight": _rand(dim, scale=0.05),
"attn2.norm_k.weight": _rand(dim, scale=0.05),
"ffn.fc_in.weight": _rand(ffn, dim, scale=0.05),
"ffn.fc_in.bias": _rand(ffn, scale=0.05),
"ffn.fc_out.weight": _rand(dim, ffn, scale=0.05),
"ffn.fc_out.bias": _rand(dim, scale=0.05),
})
return weights
def test_transformer_block_compiles_and_matches_eager() -> None:
"""The full dense block (the mx.compile target's body) traces and matches."""
dim, num_heads, head_dim, ffn, seq, ctx = 64, 4, 16, 128, 120, 32
block = MLXWanTransformerBlock(_tiny_block_weights(dim, ffn), dim=dim, ffn_dim=ffn, num_heads=num_heads, eps=1e-6)
hidden = _rand(1, seq, dim, scale=0.05)
context = _rand(1, ctx, dim, scale=0.05)
temb = _rand(1, 6, dim, scale=0.05)
cos = _rand(seq, head_dim)
sin = _rand(seq, head_dim)
eager = block(hidden, context, temb, (cos, sin))
mx.eval(eager)
compiled = mx.compile(lambda h, e, t, c, s: block(h, e, t, (c, s)))(hidden, context, temb, cos, sin)
mx.eval(compiled)
_assert_compile_parity(eager, compiled)
@@ -0,0 +1,77 @@
# SPDX-License-Identifier: Apache-2.0
"""Regression tests for preserving MLX compile flags on raw Diffusers loads."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
mx = pytest.importorskip("mlx.core", reason="MLX required for compile dispatch")
def _write_tiny_diffusers_checkpoint(tmp_path: Path) -> tuple[Path, Path]:
import torch
from safetensors.torch import save_file
config = {
"num_layers": 0,
"num_attention_heads": 1,
"attention_head_dim": 2,
"ffn_dim": 4,
"in_channels": 16,
"out_channels": 16,
"patch_size": [1, 2, 2],
"freq_dim": 2,
"eps": 1e-6,
}
config_path = tmp_path / "config.json"
checkpoint_path = tmp_path / "diffusion_pytorch_model.safetensors"
config_path.write_text(json.dumps(config))
hidden = 2
weights = {
"patch_embedding.weight": torch.zeros(hidden, 64),
"condition_embedder.time_embedder.linear_1.weight": torch.zeros(hidden, 2),
"condition_embedder.time_embedder.linear_2.weight": torch.zeros(hidden, hidden),
"condition_embedder.time_proj.weight": torch.zeros(hidden * 6, hidden),
"condition_embedder.text_embedder.linear_1.weight": torch.zeros(hidden, 4096),
"condition_embedder.text_embedder.linear_2.weight": torch.zeros(hidden, hidden),
"scale_shift_table": torch.zeros(2, hidden),
"proj_out.weight": torch.zeros(64, hidden),
}
save_file(weights, checkpoint_path)
return checkpoint_path, config_path
def test_diffusers_loader_preserves_compile_flag(tmp_path: Path) -> None:
from fastvideo.mlx_runtime.fastwan import mlx_dit_from_diffusers_safetensors
checkpoint_path, config_path = _write_tiny_diffusers_checkpoint(tmp_path)
compiled = mlx_dit_from_diffusers_safetensors(
checkpoint_path,
config_path,
compile=True,
num_blocks=0,
quantization=None,
)
eager = mlx_dit_from_diffusers_safetensors(
checkpoint_path,
config_path,
compile=False,
num_blocks=0,
quantization=None,
)
assert compiled._enable_compile is True
assert eager._enable_compile is False
def test_prompt_to_video_cli_passes_compile_to_diffusers_loader() -> None:
root = Path(__file__).resolve().parents[3]
script = root / "examples/inference/basic/mlx_wan_prompt_to_video.py"
source = script.read_text()
assert "compile=args.mlx_compile" in source
@@ -0,0 +1,93 @@
# SPDX-License-Identifier: Apache-2.0
"""Full-DiT parity: the MLX Wan runtime vs the PyTorch reference model.
This is the M1 "trustworthy baseline" gate for the Apple Silicon path: a tiny
random-weight ``WanTransformer3DModel`` is run end to end (patch embed ->
condition -> transformer blocks -> unpatchify) in PyTorch and in
``fastvideo.mlx_runtime.fastwan.MLXWanDiT`` with identical weights, and the
outputs must match within pinned fp32 tolerances.
Runs anywhere MLX is installed: on Apple Silicon it exercises the Metal
device, on Linux/CI it runs on MLX's CPU backend (``pip install 'mlx[cpu]'``)
-- the graph is identical, so CPU parity is the CI-friendly golden variant.
pytest fastvideo/tests/mlx/test_mlx_dit_parity.py -v
"""
from __future__ import annotations
import numpy as np
import pytest
pytest.importorskip("mlx.core", reason="MLX is required for DiT parity tests")
from fastvideo.mlx_runtime.fastwan import MLXQuantizationSpec # noqa: E402
from fastvideo.tests.mlx.tiny_wan import ( # noqa: E402
build_hf_config,
build_inputs,
build_tiny_wan_config,
build_torch_model,
mlx_dit_from_torch_model,
mlx_output,
mlx_rotary_embeddings,
torch_reference_output,
)
# Pinned fp32 tolerances for the full forward (patch embed through unpatchify),
# matching the single-block parity example. Measured max_abs_diff on the MLX
# CPU backend is ~1.7e-6, so 2e-4 keeps ~100x headroom for accumulation-order
# differences across MLX backends while still catching real math/layout bugs.
FP32_ATOL = 2e-4
FP32_RTOL = 2e-4
# Quantized inference is lossy by design; gate it on signal-to-noise vs the
# fp32 MLX output instead of elementwise closeness. int8 (group size 64) on
# this tiny model measures ~43 dB; 20 dB leaves headroom without letting a
# broken dequant path (which lands near 0 dB) slip through.
INT8_MIN_SNR_DB = 20.0
def test_full_dit_forward_matches_torch_reference(distributed_setup) -> None:
model = build_torch_model()
hidden_states, encoder_hidden_states, timestep = build_inputs()
torch_out = torch_reference_output(model, hidden_states, encoder_hidden_states, timestep)
dit = mlx_dit_from_torch_model(model, build_hf_config(build_tiny_wan_config()))
mlx_out = mlx_output(dit, hidden_states, encoder_hidden_states, timestep, mlx_rotary_embeddings(hidden_states))
assert mlx_out.shape == torch_out.shape
assert np.isfinite(mlx_out).all()
max_abs = float(np.abs(torch_out - mlx_out).max())
assert np.allclose(torch_out, mlx_out, atol=FP32_ATOL, rtol=FP32_RTOL), (
f"MLX full-DiT forward diverged from the torch reference: max_abs_diff={max_abs:.3e} "
f"(atol={FP32_ATOL}, rtol={FP32_RTOL})")
def test_full_dit_forward_int8_stays_close_to_fp32(distributed_setup) -> None:
"""
Ensure int8 quantization preserves the MLX DiT output within the required signal-to-noise ratio.
Parameters:
distributed_setup: Test fixture providing the distributed test environment.
Raises:
AssertionError: If the int8 output contains non-finite values or its signal-to-noise ratio is below the configured minimum.
"""
model = build_torch_model()
hidden_states, encoder_hidden_states, timestep = build_inputs()
hf_config = build_hf_config(build_tiny_wan_config())
freqs_cis = mlx_rotary_embeddings(hidden_states)
fp32_out = mlx_output(
mlx_dit_from_torch_model(model, hf_config), hidden_states, encoder_hidden_states, timestep, freqs_cis)
int8_out = mlx_output(
mlx_dit_from_torch_model(model, hf_config, quantization=MLXQuantizationSpec.from_name("int8")),
hidden_states, encoder_hidden_states, timestep, freqs_cis)
assert np.isfinite(int8_out).all()
noise = float(np.mean(np.square(int8_out - fp32_out)))
signal = float(np.mean(np.square(fp32_out)))
snr_db = 10.0 * np.log10(signal / noise) if noise > 0 else float("inf")
assert snr_db >= INT8_MIN_SNR_DB, (
f"int8-quantized MLX DiT output is too far from fp32: SNR {snr_db:.1f} dB < {INT8_MIN_SNR_DB} dB")
@@ -0,0 +1,134 @@
# SPDX-License-Identifier: Apache-2.0
"""CPU-only contracts for spatial fast mode + flag composition."""
from __future__ import annotations
import numpy as np
import pytest
from examples.inference.basic.mlx_wan22_generate import DEFAULT_HEIGHT, DEFAULT_NUM_FRAMES, DEFAULT_WIDTH
from fastvideo.mlx_runtime.fast_spatial import (
DEFAULT_FAST_SPATIAL_SHARPEN,
apply_fast_spatial_upsample,
plan_fast_spatial,
resolve_spatial_mode,
)
from fastvideo.mlx_runtime.frame_upsample import upsample_frames
def _frames(count: int, height: int, width: int, seed: int = 0) -> list[np.ndarray]:
rng = np.random.default_rng(seed)
return [rng.integers(0, 256, (height, width, 3), dtype=np.uint8) for _ in range(count)]
def test_resolve_spatial_mode_priority() -> None:
assert resolve_spatial_mode(refine=False, fast_spatial=False) == "off"
assert resolve_spatial_mode(refine=False, fast_spatial=True) == "fast_spatial"
# Refine is the quality path and wins when both are requested.
assert resolve_spatial_mode(refine=True, fast_spatial=True) == "refine"
assert resolve_spatial_mode(refine=True, fast_spatial=False) == "refine"
def test_plan_fast_spatial_matches_refine_geometry() -> None:
spatial = plan_fast_spatial(
height=480,
width=832,
num_frames=81,
spatial_scale=2,
vae_spatial_compression=8,
)
assert spatial.enabled
assert spatial.stage1_height == 240
assert spatial.stage1_width == 416
assert spatial.target_height == 480
assert spatial.target_width == 832
assert spatial.plan.stage1_latent_height == 30
assert spatial.plan.stage2_latent_width == 104
assert spatial.sharpen == DEFAULT_FAST_SPATIAL_SHARPEN
def test_plan_fast_spatial_disabled() -> None:
spatial = plan_fast_spatial(height=480, width=832, num_frames=81, enabled=False)
assert not spatial.enabled
assert spatial.scale == 1
def test_plan_fast_spatial_accepts_wan22_defaults() -> None:
assert (DEFAULT_HEIGHT, DEFAULT_WIDTH, DEFAULT_NUM_FRAMES) == (448, 832, 121)
spatial = plan_fast_spatial(
height=DEFAULT_HEIGHT,
width=DEFAULT_WIDTH,
num_frames=DEFAULT_NUM_FRAMES,
spatial_scale=2,
vae_spatial_compression=16,
vae_temporal_compression=4,
patch_size=(1, 2, 2),
)
assert (spatial.plan.stage1_latent_height, spatial.plan.stage1_latent_width) == (14, 26)
assert (spatial.plan.stage2_latent_height, spatial.plan.stage2_latent_width) == (28, 52)
def test_apply_fast_spatial_upsample_resizes_decoded_frames() -> None:
"""The upsample is pixel-space: it takes decoded frames, not latents.
Interpolating latents instead is what made spatial fast mode incoherent —
a blended Wan latent is off the decoder's manifold, so the decode came back
as the right silhouette under a smeared veil.
"""
spatial = plan_fast_spatial(
height=480,
width=832,
num_frames=81,
spatial_scale=2,
vae_spatial_compression=8,
upsample_mode="lanczos",
sharpen=0.0,
)
frames = _frames(3, 240, 416)
out = apply_fast_spatial_upsample(frames, spatial)
assert len(out) == 3
assert all(frame.shape == (480, 832, 3) for frame in out)
assert all(frame.dtype == np.uint8 for frame in out)
expected = upsample_frames(frames, width=832, height=480, mode="lanczos", sharpen=0.0)
for got, want in zip(out, expected, strict=True):
np.testing.assert_array_equal(got, want)
# Inputs are not mutated.
assert all(frame.shape == (240, 416, 3) for frame in frames)
def test_apply_fast_spatial_upsample_applies_sharpen() -> None:
frames = _frames(1, 240, 416)
soft = plan_fast_spatial(height=480, width=832, num_frames=81, spatial_scale=2, sharpen=0.0)
crisp = plan_fast_spatial(height=480, width=832, num_frames=81, spatial_scale=2, sharpen=0.8)
assert not np.array_equal(
apply_fast_spatial_upsample(frames, soft)[0],
apply_fast_spatial_upsample(frames, crisp)[0],
)
def test_apply_fast_spatial_noop_when_disabled() -> None:
frames = _frames(2, 32, 32, seed=1)
spatial = plan_fast_spatial(height=32, width=32, num_frames=5, enabled=False, patch_size=(1, 1, 1))
out = apply_fast_spatial_upsample(frames, spatial)
for got, want in zip(out, frames, strict=True):
np.testing.assert_array_equal(got, want)
def test_plan_fast_spatial_rejects_bad_mode() -> None:
with pytest.raises(ValueError, match="upsample mode"):
plan_fast_spatial(height=480, width=832, num_frames=81, upsample_mode="bicubic")
def test_plan_fast_spatial_rejects_negative_sharpen() -> None:
with pytest.raises(ValueError, match="sharpen"):
plan_fast_spatial(height=480, width=832, num_frames=81, sharpen=-0.1)
def test_geometry_errors_name_fast_spatial_not_refine() -> None:
"""Both modes share the resolution splitter; the error must name the caller.
832 is not divisible by 3, so scale 3 is rejected at plan time.
"""
with pytest.raises(ValueError, match=r"^fast-spatial requires"):
plan_fast_spatial(height=480, width=832, num_frames=81, spatial_scale=3)
@@ -0,0 +1,141 @@
# SPDX-License-Identifier: Apache-2.0
import pytest
from fastvideo.benchmarks.mlx_fastwan_bench import (
ALLOWED_MODES,
BENCHMARK_PRESETS,
_html_grid,
_load_prompt_cases,
_mode_to_dtype_quant,
_parse_list,
)
@pytest.mark.parametrize(
("mode", "expected"),
[
("fp16", ("fp16", None)),
("bf16", ("bf16", None)),
("int8", ("fp16", "int8")),
("int4", ("fp16", "int4")),
("mxfp8", ("fp16", "mxfp8")),
("mxfp4", ("fp16", "mxfp4")),
("nvfp4", ("fp16", "nvfp4")),
],
)
def test_benchmark_modes_map_to_runtime_quantization(mode: str, expected: tuple[str, str | None]) -> None:
assert mode in ALLOWED_MODES
assert _mode_to_dtype_quant(mode) == expected
def test_benchmark_rejects_unknown_modes() -> None:
with pytest.raises(ValueError, match="Unsupported modes"):
_parse_list("fp16,not_a_mode", ALLOWED_MODES, "modes")
def test_load_prompt_cases_from_plain_text(tmp_path) -> None:
prompt_file = tmp_path / "prompts.txt"
prompt_file.write_text("\n# comment\nA fox runs through a forest.\nA raccoon walks in sunflowers.\n")
cases = _load_prompt_cases("unused", prompt_file)
assert [case.id for case in cases] == ["prompt-001", "prompt-002"]
assert [case.prompt for case in cases] == [
"A fox runs through a forest.",
"A raccoon walks in sunflowers.",
]
def test_load_prompt_cases_from_jsonl(tmp_path) -> None:
prompt_file = tmp_path / "prompts.jsonl"
prompt_file.write_text('{"id": "Fox Forest", "prompt": "A fox runs."}\n{"name": "clock", "caption": "A clock burns."}\n')
cases = _load_prompt_cases("unused", prompt_file)
assert [case.id for case in cases] == ["fox-forest", "clock"]
assert [case.prompt for case in cases] == ["A fox runs.", "A clock burns."]
def test_load_builtin_prompt_set() -> None:
cases = _load_prompt_cases("unused", None, "motion7")
assert len(cases) == 7
assert cases[0].id == "beach-sunset"
def test_benchmark_presets_include_memory_tiers() -> None:
assert BENCHMARK_PRESETS["mac-16gb"].modes == "int8"
assert BENCHMARK_PRESETS["mac-16gb"].decoders == "taehv"
assert BENCHMARK_PRESETS["mac-16gb"].mlx_memory_limit_gib == 16.0
assert BENCHMARK_PRESETS["mac-64gb"].decoders == "taehv,wan-vae"
def test_html_grid_includes_video_and_sync_controls() -> None:
rendered = _html_grid([
{
"prompt_id": "fox",
"prompt": "A fox runs.",
"mode": "int8",
"decoder": "taehv",
"status": "ok",
"video_path": "fox/video_int8_taehv.mp4",
"total_s": 12.3,
"denoise_s": 10.0,
"decode_s": 1.0,
"peak_gib": 4.5,
}
])
assert "Restart + play all" in rendered
assert "fox/video_int8_taehv.mp4" in rendered
assert "A fox runs." in rendered
def test_denoise_dmd_on_device_runs_tiny_dit_and_reports_step_times(distributed_setup) -> None:
mx = pytest.importorskip("mlx.core", reason="MLX is required for the on-device denoise test")
import numpy as np
import torch
from fastvideo.benchmarks.mlx_fastwan_bench import denoise_dmd_on_device
from fastvideo.mlx_runtime.sampling import MLXDMDSchedule, dmd_step
from fastvideo.models.schedulers.scheduling_flow_match_euler_discrete import (
FlowMatchEulerDiscreteScheduler,
)
from fastvideo.tests.mlx.tiny_wan import (
build_hf_config,
build_inputs,
build_tiny_wan_config,
build_torch_model,
mlx_dit_from_torch_model,
mlx_rotary_embeddings,
)
dit = mlx_dit_from_torch_model(build_torch_model(), build_hf_config(build_tiny_wan_config()))
hidden_states, encoder_hidden_states, _ = build_inputs()
schedule = MLXDMDSchedule.from_torch_scheduler(FlowMatchEulerDiscreteScheduler(shift=8.0))
timesteps = [1000, 757, 522]
generator = torch.Generator(device="cpu").manual_seed(7)
latents_seed = torch.randn(hidden_states.shape, generator=generator, dtype=torch.float32).numpy()
renoise_by_step = [
torch.randn(hidden_states.shape, generator=generator, dtype=torch.float32).numpy()
for _ in range(len(timesteps) - 1)
]
latents_np, step_times = denoise_dmd_on_device(
mx=mx,
dit=dit,
latents=mx.array(latents_seed),
encoder_hidden_states=mx.array(encoder_hidden_states.numpy()),
freqs_cis=mlx_rotary_embeddings(hidden_states),
timesteps=timesteps,
renoise_by_step=renoise_by_step,
schedule=schedule,
dmd_step=dmd_step,
mx_dtype=mx.float32,
)
assert latents_np.shape == tuple(hidden_states.shape)
assert np.isfinite(latents_np).all()
assert len(step_times) == len(timesteps)
assert all(t > 0 for t in step_times)
@@ -0,0 +1,86 @@
# SPDX-License-Identifier: Apache-2.0
"""CPU-only contracts for local prompt enrichment."""
from __future__ import annotations
import json
from fastvideo.mlx_runtime.prompt_enhance import (
DEFAULT_ENHANCE_SYSTEM_PROMPT,
enhance_prompt,
enhance_prompt_template,
enhance_result_as_metrics,
load_or_enhance_prompt,
)
def test_template_adds_cinematic_cues_to_thin_prompt() -> None:
raw = "A red fox in the snow"
out = enhance_prompt_template(raw)
assert raw in out
lower = out.lower()
assert "lens" in lower or "camera" in lower or "cinematic" in lower
assert "light" in lower
assert out.endswith(".")
def test_template_is_idempotent_on_rich_prompts() -> None:
rich = (
"A red fox trotting through a snowy pine forest at golden hour, "
"cinematic wide shot on a 35mm lens, soft volumetric lighting, "
"gentle camera dolly, highly detailed film grain and color graded."
)
assert enhance_prompt_template(rich) == " ".join(rich.split())
def test_enhance_prompt_template_backend() -> None:
result = enhance_prompt("a paper boat on a stream", backend="template")
assert result.backend == "template"
assert result.changed
assert result.original == "a paper boat on a stream"
assert "paper boat" in result.enhanced.lower()
def test_enhance_prompt_rejects_empty() -> None:
try:
enhance_prompt(" ", backend="template")
except ValueError as exc:
assert "non-empty" in str(exc)
else: # pragma: no cover
raise AssertionError("expected ValueError")
def test_load_or_enhance_prompt_caches(tmp_path) -> None:
result1 = load_or_enhance_prompt(
"a lantern in the fog",
backend="template",
cache=True,
cache_dir=tmp_path,
)
assert result1.backend == "template"
cached_files = list(tmp_path.glob("*.json"))
assert len(cached_files) == 1
payload = json.loads(cached_files[0].read_text())
assert payload["enhanced"] == result1.enhanced
result2 = load_or_enhance_prompt(
"a lantern in the fog",
backend="template",
cache=True,
cache_dir=tmp_path,
)
assert result2.enhanced == result1.enhanced
assert result2.backend == "cache"
assert result2.elapsed_s == 0.0
def test_enhance_result_as_metrics_none() -> None:
metrics = enhance_result_as_metrics(None)
assert metrics["enhance_prompt"] is False
assert metrics["prompt_enhanced"] is None
def test_default_system_prompt_matches_streaming_contract() -> None:
# Lockstep with fastvideo/entrypoints/streaming/prompt/enhancer.py
assert "cinematic video generation" in DEFAULT_ENHANCE_SYSTEM_PROMPT.lower()
assert "just the enhanced prompt" in DEFAULT_ENHANCE_SYSTEM_PROMPT.lower()
@@ -0,0 +1,85 @@
# SPDX-License-Identifier: Apache-2.0
"""Regression tests for the MLX prompt-to-video decode dispatch."""
from __future__ import annotations
import importlib.util
from pathlib import Path
import numpy as np
def _load_prompt_to_video_module():
root = Path(__file__).resolve().parents[3]
script = root / "examples/inference/basic/mlx_wan_prompt_to_video.py"
spec = importlib.util.spec_from_file_location(
"mlx_wan_prompt_to_video_for_test", script
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_default_taehv_decode_uses_mlx_backend(monkeypatch, tmp_path):
module = _load_prompt_to_video_module()
import fastvideo.mlx_runtime.wan_vae as wan_vae
calls = []
def fake_decode_latents_to_video(latents_np, output_path, **kwargs):
calls.append((latents_np, output_path, kwargs))
output_path.write_bytes(b"fake mp4")
return {"backend": kwargs["backend"]}
monkeypatch.setattr(
wan_vae, "decode_latents_to_video", fake_decode_latents_to_video
)
latents = np.zeros((1, 16, 1, 2, 2), dtype=np.float32)
output_path = tmp_path / "out.mp4"
module.decode_latents_to_video(
model_root=tmp_path,
latents_np=latents,
output_path=output_path,
fps=16,
device_arg="cpu",
dtype_arg="fp32",
backend="taehv",
taehv_source_path=None,
taehv_checkpoint_path=None,
taehv_parallel=False,
)
assert output_path.read_bytes() == b"fake mp4"
assert len(calls) == 1
_, called_output_path, kwargs = calls[0]
assert called_output_path == output_path
assert kwargs["backend"] == "taehv"
assert kwargs["z_dim"] == 16
def test_vendored_taehv_import_available():
from fastvideo.third_party.taehv import TAEHV
assert TAEHV.__name__ == "TAEHV"
def test_default_model_resolution_downloads_only_needed_transformer(monkeypatch, tmp_path):
module = _load_prompt_to_video_module()
import huggingface_hub
calls = []
def fake_snapshot_download(model_id, allow_patterns):
calls.append((model_id, allow_patterns))
return str(tmp_path)
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
assert module.resolve_model_root(None, include_transformer=True) == tmp_path
assert "transformer/*" in calls[-1][1]
assert module.resolve_model_root(None, include_transformer=False) == tmp_path
assert "transformer/*" not in calls[-1][1]
assert module.resolve_model_root(tmp_path) == tmp_path
assert len(calls) == 2
+248
View File
@@ -0,0 +1,248 @@
# SPDX-License-Identifier: Apache-2.0
"""CPU-only contracts for the MLX two-pass refine pipeline.
Mirrors the LTX-2 refine stage math (init resolution split, spatial
upsample, Gaussian re-noise) without requiring Metal / a real DiT. The
orchestration helpers that need ``mx`` are exercised only when MLX is
importable.
"""
from __future__ import annotations
import math
import numpy as np
import pytest
from examples.inference.basic.mlx_wan22_generate import DEFAULT_HEIGHT, DEFAULT_NUM_FRAMES, DEFAULT_WIDTH
from fastvideo.mlx_runtime.refine import (
DEFAULT_REFINE_SIGMA,
default_refine_timesteps,
plan_refine_resolutions,
prepare_refine_latents,
refine_sigma_from_schedule,
upsample_latents_spatial,
)
from fastvideo.mlx_runtime.sampling import MLXDMDSchedule, add_noise
def _fastwan_schedule() -> MLXDMDSchedule:
"""FastWan's flow-match schedule: ``sigma == timestep / 1000``."""
timesteps = np.arange(1000, 0, -1, dtype=np.float64)
return MLXDMDSchedule(sigmas=timesteps / 1000.0, timesteps=timesteps)
def test_plan_refine_resolutions_splits_even_target() -> None:
plan = plan_refine_resolutions(
height=480,
width=832,
num_frames=81,
spatial_scale=2,
vae_spatial_compression=8,
vae_temporal_compression=4,
patch_size=(1, 2, 2),
)
assert plan.stage1_height == 240
assert plan.stage1_width == 416
assert plan.target_height == 480
assert plan.target_width == 832
assert plan.stage1_latent_height == 30
assert plan.stage1_latent_width == 52
assert plan.stage2_latent_height == 60
assert plan.stage2_latent_width == 104
assert plan.latent_frames == 21
assert plan.spatial_scale == 2
def test_plan_refine_disabled_collapses_to_single_pass() -> None:
plan = plan_refine_resolutions(
height=480,
width=832,
num_frames=81,
spatial_scale=2,
enabled=False,
)
assert plan.spatial_scale == 1
assert plan.stage1_height == plan.target_height == 480
assert plan.stage1_width == plan.target_width == 832
@pytest.mark.parametrize(
("height", "width", "num_frames", "message"),
[(481, 832, 81, "height/width"), (480, 833, 81, "height/width"), (480, 832, 82, "num_frames")],
)
def test_plan_refine_rejects_requests_the_vae_would_truncate(
height: int,
width: int,
num_frames: int,
message: str,
) -> None:
with pytest.raises(ValueError, match=message):
plan_refine_resolutions(height=height, width=width, num_frames=num_frames, enabled=False)
def test_plan_refine_rejects_odd_target() -> None:
with pytest.raises(ValueError, match="divisible by"):
plan_refine_resolutions(height=481, width=832, num_frames=81, spatial_scale=2)
def test_plan_refine_rejects_vae_misaligned_stage1() -> None:
# 500/2 = 250, 250 % 8 != 0 → rejected (same guard as LTX2RefineInitStage).
with pytest.raises(ValueError, match="divisible by"):
plan_refine_resolutions(
height=500,
width=800,
num_frames=81,
spatial_scale=2,
vae_spatial_compression=8,
)
def test_plan_refine_wan22_5b_720p_geometry() -> None:
# Wan2.2 TI2V-5B native 704x1280 with 16x spatial VAE compression.
plan = plan_refine_resolutions(
height=704,
width=1280,
num_frames=121,
spatial_scale=2,
vae_spatial_compression=16,
vae_temporal_compression=4,
patch_size=(1, 2, 2),
)
assert plan.stage1_height == 352
assert plan.stage1_width == 640
assert plan.stage1_latent_height == 22
assert plan.stage1_latent_width == 40
assert plan.stage2_latent_height == 44
assert plan.stage2_latent_width == 80
assert plan.latent_frames == 31
def test_plan_refine_wan22_default_geometry() -> None:
plan = plan_refine_resolutions(
height=DEFAULT_HEIGHT,
width=DEFAULT_WIDTH,
num_frames=DEFAULT_NUM_FRAMES,
spatial_scale=2,
vae_spatial_compression=16,
vae_temporal_compression=4,
patch_size=(1, 2, 2),
)
assert (plan.stage1_latent_height, plan.stage1_latent_width) == (14, 26)
assert (plan.stage2_latent_height, plan.stage2_latent_width) == (28, 52)
assert plan.latent_frames == 31
def test_upsample_nearest_repeats_spatial_samples() -> None:
rng = np.random.default_rng(0)
latents = rng.standard_normal((1, 4, 2, 3, 5)).astype(np.float32)
up = upsample_latents_spatial(latents, scale=2, mode="nearest")
assert up.shape == (1, 4, 2, 6, 10)
# Every 2x2 block equals the source sample broadcast across the block.
for y in range(3):
for x in range(5):
block = up[0, :, :, 2 * y:2 * y + 2, 2 * x:2 * x + 2]
src = latents[0, :, :, y, x][:, :, None, None]
np.testing.assert_array_equal(block, np.broadcast_to(src, block.shape))
def test_upsample_bilinear_preserves_constants() -> None:
latents = np.full((1, 2, 1, 4, 4), 3.5, dtype=np.float32)
up = upsample_latents_spatial(latents, scale=2, mode="bilinear")
assert up.shape == (1, 2, 1, 8, 8)
np.testing.assert_allclose(up, 3.5, rtol=0, atol=1e-6)
def test_upsample_scale_one_is_identity() -> None:
rng = np.random.default_rng(1)
latents = rng.standard_normal((1, 2, 1, 4, 4)).astype(np.float32)
out = upsample_latents_spatial(latents, scale=1)
np.testing.assert_array_equal(out, latents)
def test_prepare_refine_latents_matches_add_noise_formula() -> None:
rng = np.random.default_rng(2)
clean = rng.standard_normal((1, 4, 2, 3, 5)).astype(np.float32)
noise = rng.standard_normal((1, 4, 2, 6, 10)).astype(np.float32)
sigma = 0.75
got = prepare_refine_latents(
clean,
scale=2,
sigma=sigma,
noise=noise,
add_noise_flag=True,
upsample_mode="nearest",
)
up = upsample_latents_spatial(clean, scale=2, mode="nearest")
expected = add_noise(up, noise, sigma)
np.testing.assert_allclose(got, expected, rtol=0, atol=0)
def test_prepare_refine_latents_no_noise_returns_upsample() -> None:
rng = np.random.default_rng(3)
clean = rng.standard_normal((1, 2, 1, 4, 4)).astype(np.float32)
got = prepare_refine_latents(clean, scale=2, add_noise_flag=False, upsample_mode="nearest")
expected = upsample_latents_spatial(clean, scale=2, mode="nearest")
np.testing.assert_array_equal(got, expected)
def test_prepare_refine_latents_seed_is_deterministic() -> None:
rng = np.random.default_rng(4)
clean = rng.standard_normal((1, 2, 1, 3, 3)).astype(np.float32)
a = prepare_refine_latents(clean, scale=2, sigma=0.5, seed=123, upsample_mode="nearest")
b = prepare_refine_latents(clean, scale=2, sigma=0.5, seed=123, upsample_mode="nearest")
np.testing.assert_array_equal(a, b)
def test_refine_sigma_from_schedule_uses_first_timestep() -> None:
timesteps = np.arange(1000, dtype=np.float64)
sigmas = np.linspace(1.0, 1e-3, 1000, dtype=np.float64)
schedule = MLXDMDSchedule(sigmas=sigmas, timesteps=timesteps)
# First refine timestep drives the stage-2 start sigma.
assert refine_sigma_from_schedule(schedule, [999, 757, 522]) == pytest.approx(float(sigmas[999]))
assert refine_sigma_from_schedule(schedule, [522]) == pytest.approx(float(sigmas[522]))
def test_default_refine_sigma_matches_ltx2_stage2_head() -> None:
# Documented contract: DEFAULT_REFINE_SIGMA tracks LTX-2's first
# STAGE_2_DISTILLED_SIGMA_VALUES entry so the fallback is intentional.
assert math.isclose(DEFAULT_REFINE_SIGMA, 0.909375, rel_tol=0, abs_tol=0)
def test_full_noise_handoff_would_discard_stage1() -> None:
"""Why the stage-2 grid may not open at the stage-1 timestep.
FastWan's DMD grid starts at ``t=1000``, i.e. ``sigma == 1``. The hand-off
is ``(1 - sigma) * upsampled + sigma * noise``, so starting stage 2 there
weights the stage-1 result at exactly zero: refine silently degrades to a
plain full-resolution run costing two passes.
"""
schedule = _fastwan_schedule()
assert refine_sigma_from_schedule(schedule, [1000, 757, 522]) == pytest.approx(1.0)
clean = np.random.default_rng(0).standard_normal((1, 2, 1, 4, 4)).astype(np.float32)
handoff = prepare_refine_latents(clean, scale=2, sigma=1.0, seed=7, upsample_mode="nearest")
upsampled = upsample_latents_spatial(clean, scale=2, mode="nearest")
# Nothing of stage 1 survives.
assert not np.allclose(handoff, upsampled)
def test_default_refine_timesteps_drops_the_full_noise_step() -> None:
schedule = _fastwan_schedule()
steps = default_refine_timesteps(schedule, [1000, 757, 522])
assert steps == [757.0, 522.0]
# The resulting hand-off keeps a real share of stage 1.
sigma = refine_sigma_from_schedule(schedule, steps)
assert 0.0 < sigma < 1.0
def test_default_refine_timesteps_keeps_an_already_valid_grid() -> None:
schedule = _fastwan_schedule()
assert default_refine_timesteps(schedule, [757, 522]) == [757.0, 522.0]
def test_default_refine_timesteps_rejects_an_all_full_noise_grid() -> None:
schedule = _fastwan_schedule()
with pytest.raises(ValueError, match="No usable refine timesteps"):
default_refine_timesteps(schedule, [1000])
@@ -0,0 +1,63 @@
# SPDX-License-Identifier: Apache-2.0
"""Regression tests for the Apple-Silicon MLX RIFE interpolation backend."""
from __future__ import annotations
import numpy as np
import pytest
from huggingface_hub.utils import LocalEntryNotFoundError
mx = pytest.importorskip("mlx.core", reason="MLX required for RIFE tests")
@pytest.mark.skipif(
not bool(getattr(mx, "metal", None) and mx.metal.is_available()),
reason="RIFE MLX regression requires Apple Silicon Metal",
)
def test_rife_interpolation_preserves_keyframes_shape_and_count() -> None:
from fastvideo.mlx_runtime.rife_interp import RIFEWeightsUnavailableError, interpolate, load_model
frame0 = np.zeros((64, 96, 3), dtype=np.uint8)
frame1 = np.zeros((64, 96, 3), dtype=np.uint8)
frame1[:, :, 0] = 255
try:
# The backend is vendored, but its weights are fetched from Hugging Face
# on first use, so an offline machine should skip rather than fail.
model = load_model()
except RIFEWeightsUnavailableError as exc:
pytest.skip(f"RIFE weights unavailable: {exc}")
frames = interpolate([frame0, frame1], factor=2, model=model)
assert len(frames) == 3
assert frames[1].shape == frame0.shape
assert frames[1].dtype == np.uint8
np.testing.assert_array_equal(frames[0], frame0)
np.testing.assert_array_equal(frames[-1], frame1)
def test_rife_download_unavailable_has_specific_error(monkeypatch) -> None:
from fastvideo.mlx_runtime.rife_interp import RIFEWeightsUnavailableError, load_model
from fastvideo.third_party.rife_mlx.utils import weights
def unavailable(*args, **kwargs):
raise LocalEntryNotFoundError("offline")
monkeypatch.setattr(weights, "build_model", unavailable)
load_model.cache_clear()
with pytest.raises(RIFEWeightsUnavailableError):
load_model()
def test_rife_backend_regression_is_not_skip_eligible(monkeypatch) -> None:
from fastvideo.mlx_runtime.rife_interp import RIFEBackendError, RIFEWeightsUnavailableError, load_model
from fastvideo.third_party.rife_mlx.utils import weights
def broken(*args, **kwargs):
raise AssertionError("backend regression")
monkeypatch.setattr(weights, "build_model", broken)
load_model.cache_clear()
with pytest.raises(RIFEBackendError) as caught:
load_model()
assert not isinstance(caught.value, RIFEWeightsUnavailableError)
@@ -0,0 +1,40 @@
# SPDX-License-Identifier: Apache-2.0
"""TAEHV decode parity tests for the MLX implementation.
TAEHV MLX vs torch is bit-close (atol 1e-5) for z_dim=16 and 48.
"""
from __future__ import annotations
import numpy as np
import pytest
mx = pytest.importorskip("mlx.core", reason="MLX required for Wan VAE/TAEHV tests")
from fastvideo.mlx_runtime.wan_vae import ( # noqa: E402
decode_latents_taehv_mlx,
ensure_taehv_checkpoint,
)
@pytest.mark.parametrize("z_dim", [16, 48])
def test_taehv_mlx_matches_torch(z_dim: int) -> None:
import torch
from fastvideo.third_party.taehv import TAEHV
ckpt = ensure_taehv_checkpoint(z_dim=z_dim)
rng = np.random.default_rng(0)
h = w = 8
t = 5
lat = (rng.standard_normal((1, z_dim, t, h, w)) * 0.5).astype(np.float32)
model = TAEHV(str(ckpt)).eval()
with torch.no_grad():
out_t = model.decode_video(torch.from_numpy(lat).transpose(1, 2), parallel=True, show_progress_bar=False)
torch_np = out_t[0].permute(0, 2, 3, 1).float().numpy()
mlx_np = decode_latents_taehv_mlx(lat, z_dim=z_dim)[0]
# Validate complete temporal output shape before comparing values.
assert mlx_np.shape[0] == torch_np.shape[0], (
f"MLX output frame count {mlx_np.shape[0]} != torch {torch_np.shape[0]}"
)
np.testing.assert_allclose(mlx_np, torch_np, atol=1e-5, rtol=1e-5)
@@ -0,0 +1,116 @@
# SPDX-License-Identifier: Apache-2.0
"""Regression test for the Wan2.2 MLX/MPS memory handoff."""
from __future__ import annotations
import gc
import importlib.util
import json
import sys
import weakref
from pathlib import Path
import numpy as np
def _load_wan22_module():
root = Path(__file__).resolve().parents[3]
script = root / "examples/inference/basic/mlx_wan22_generate.py"
spec = importlib.util.spec_from_file_location("mlx_wan22_generate_memory_test", script)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_wan22_releases_compiled_dit_before_torch_decode_and_mlx_rife(monkeypatch, tmp_path: Path) -> None:
import mlx.core as mx
import torch
from diffusers import utils as diffusers_utils
from examples.inference.basic import mlx_wan_prompt_to_video as shared
from fastvideo.mlx_runtime import wan22, wan22_sample, wan_vae
module = _load_wan22_module()
checkpoint = tmp_path / "mlx"
checkpoint.mkdir()
(checkpoint / "mlx_dit.json").write_text(
json.dumps({"config": {"patch_size": [1, 2, 2], "in_channels": 1}})
)
events: list[str] = []
dit_ref = None
class CyclicCompiledDiT:
def __init__(self) -> None:
self.compiled = self.forward
def forward(self) -> None:
pass
def fake_load(*args, **kwargs):
nonlocal dit_ref
assert kwargs["compile"] is True
dit = CyclicCompiledDiT()
dit_ref = weakref.ref(dit)
return dit
def fake_decode(*args, **kwargs):
events.append("decode")
assert dit_ref is not None and dit_ref() is None
assert events[-3:] == ["gc", "mlx-clear", "decode"]
return np.zeros((1, 1, 2, 2, 3), dtype=np.float32)
def fake_postprocess(**kwargs) -> None:
events.append("rife")
assert events[-4:] == ["decode", "gc", "mps-clear", "rife"]
real_collect = gc.collect
def tracked_collect() -> int:
events.append("gc")
return real_collect()
monkeypatch.setattr(shared, "encode_prompt", lambda **kwargs: torch.zeros((1, 1, 1)))
monkeypatch.setattr(shared, "make_rotary_embeddings", lambda *args, **kwargs: None)
monkeypatch.setattr(shared, "_postprocess_video", fake_postprocess)
monkeypatch.setattr(wan22, "mlx_wan22_dit_from_mlx_checkpoint", fake_load)
monkeypatch.setattr(wan22_sample, "sample_wan22_dmd", lambda *args, **kwargs: args[2])
monkeypatch.setattr(wan_vae, "decode_latents_wan_vae_torch", fake_decode)
monkeypatch.setattr(diffusers_utils, "export_to_video", lambda *args, **kwargs: None)
monkeypatch.setattr(gc, "collect", tracked_collect)
monkeypatch.setattr(mx, "clear_cache", lambda: events.append("mlx-clear"))
monkeypatch.setattr(torch.backends.mps, "is_available", lambda: True)
monkeypatch.setattr(torch.mps, "empty_cache", lambda: events.append("mps-clear"))
monkeypatch.setattr(
sys,
"argv",
[
"mlx_wan22_generate.py",
"--text-encoder-root",
str(tmp_path / "text"),
"--no-prompt-cache",
"--mlx-checkpoint",
str(checkpoint),
"--height",
"32",
"--width",
"32",
"--num-frames",
"1",
"--dmd-denoising-steps",
"1",
"--compile",
"--fast",
"--decode-backend",
"wan-vae",
"--vae-root",
str(tmp_path / "vae"),
"--output-path",
str(tmp_path / "out.mp4"),
],
)
module.main()
assert events == ["gc", "mlx-clear", "decode", "gc", "mps-clear", "rife"]
@@ -0,0 +1,142 @@
# SPDX-License-Identifier: Apache-2.0
"""Track D Rung 2: per-token-timestep parity for the Wan2.2-TI2V-5B MLX port.
The 5B (FullAttn) differs from the ported 2.1 only in scale (config) and
*per-token* timestep conditioning (``expand_timesteps``: timestep is ``[B, L]``,
one noise level per patch token — how TI2V keeps the image frame at t=0 while
video frames are noised). Passing a 2-D timestep drives the torch model's
per-token path (``ts_seq_len``), so this compares ``MLXWan22DiT`` against the
torch reference on a tiny random-weight config. This is the run-6 prereq gate;
runs on CPU in CI. Real 5B-weight parity is a separate Metal-gated test.
"""
from __future__ import annotations
import numpy as np
import pytest
import torch
mx = pytest.importorskip("mlx.core", reason="MLX is required for the Wan2.2 parity test")
from fastvideo.forward_context import set_forward_context # noqa: E402
from fastvideo.mlx_runtime.fastwan import mlx_block_weights_from_torch # noqa: E402
from fastvideo.mlx_runtime.wan22 import ( # noqa: E402
MLXWan22DiT,
MLXWan22TransformerBlock,
)
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch # noqa: E402
from fastvideo.tests.mlx.tiny_wan import ( # noqa: E402
TINY_ARCH,
TOP_LEVEL_KEY_MAP,
build_hf_config,
build_tiny_wan_config,
build_torch_model,
mlx_rotary_embeddings,
)
def _mlx_wan22_from_torch(model, hf_config, *, compile: bool = False) -> MLXWan22DiT:
state = {name: value.detach().float() for name, value in model.state_dict().items()}
inner_dim = int(hf_config["num_attention_heads"]) * int(hf_config["attention_head_dim"])
weights = {}
for mlx_name, torch_name in TOP_LEVEL_KEY_MAP.items():
tensor = state[torch_name]
if mlx_name == "patch_embedding.weight":
tensor = tensor.reshape(inner_dim, -1)
weights[mlx_name] = mx.array(tensor.numpy())
blocks = [
MLXWan22TransformerBlock(
mlx_block_weights_from_torch(tb), dim=inner_dim, ffn_dim=int(hf_config["ffn_dim"]),
num_heads=int(hf_config["num_attention_heads"]), eps=float(hf_config["eps"])) for tb in model.blocks
]
return MLXWan22DiT(weights, blocks, dict(hf_config), compile=compile)
@pytest.mark.usefixtures("distributed_setup")
def test_wan22_per_token_timestep_matches_torch() -> None:
torch_model = build_torch_model()
hf_config = build_hf_config(build_tiny_wan_config())
mlx_model = _mlx_wan22_from_torch(torch_model, hf_config)
# Tiny latent: 4 frames, 8x8 latent, patch (1,2,2) -> 4*4*4 = 64 tokens (L).
frames, height, width = 4, 8, 8
p_t, p_h, p_w = TINY_ARCH["patch_size"]
num_tokens = (frames // p_t) * (height // p_h) * (width // p_w)
tokens_per_frame = num_tokens // (frames // p_t)
gen = torch.Generator().manual_seed(11)
hidden = torch.randn(1, TINY_ARCH["in_channels"], frames, height, width, generator=gen, dtype=torch.float32)
text = torch.randn(1, 8, TINY_ARCH["text_dim"], generator=gen, dtype=torch.float32)
# Per-token timestep [B, L]: frame 0 clean (t=0, I2V-style), rest noised (t=500).
per_frame = [0] + [500] * (frames // p_t - 1)
timestep = torch.tensor([[per_frame[i // tokens_per_frame] for i in range(num_tokens)]], dtype=torch.long)
with torch.no_grad(), set_forward_context(
current_timestep=0, attn_metadata=None, forward_batch=ForwardBatch(data_type="dummy")):
ref = torch_model(hidden_states=hidden, encoder_hidden_states=text, timestep=timestep).detach().float().numpy()
freqs_cis = mlx_rotary_embeddings(hidden)
out = mlx_model(mx.array(hidden.numpy()), mx.array(text.numpy()), mx.array(timestep.float().numpy()), freqs_cis)
mx.eval(out)
mlx_out = np.array(out.astype(mx.float32))
np.testing.assert_allclose(mlx_out, ref, atol=2e-3, rtol=2e-3)
@pytest.mark.usefixtures("distributed_setup")
def test_wan22_compile_path_matches_eager() -> None:
torch_model = build_torch_model()
hf_config = build_hf_config(build_tiny_wan_config())
eager_model = _mlx_wan22_from_torch(torch_model, hf_config)
compiled_model = _mlx_wan22_from_torch(torch_model, hf_config, compile=True)
frames, height, width = 4, 8, 8
p_t, p_h, p_w = TINY_ARCH["patch_size"]
num_tokens = (frames // p_t) * (height // p_h) * (width // p_w)
tokens_per_frame = num_tokens // (frames // p_t)
gen = torch.Generator().manual_seed(12)
hidden = torch.randn(
1,
TINY_ARCH["in_channels"],
frames,
height,
width,
generator=gen,
dtype=torch.float32,
)
text = torch.randn(
1,
8,
TINY_ARCH["text_dim"],
generator=gen,
dtype=torch.float32,
)
per_frame = [0] + [500] * (frames // p_t - 1)
timestep = torch.tensor(
[[per_frame[i // tokens_per_frame] for i in range(num_tokens)]],
dtype=torch.long,
)
freqs_cis = mlx_rotary_embeddings(hidden)
eager = eager_model(
mx.array(hidden.numpy()),
mx.array(text.numpy()),
mx.array(timestep.float().numpy()),
freqs_cis,
)
compiled = compiled_model(
mx.array(hidden.numpy()),
mx.array(text.numpy()),
mx.array(timestep.float().numpy()),
freqs_cis,
)
mx.eval(eager, compiled)
np.testing.assert_allclose(
np.array(compiled.astype(mx.float32)),
np.array(eager.astype(mx.float32)),
atol=1e-4,
rtol=1e-4,
)
@@ -0,0 +1,318 @@
# SPDX-License-Identifier: Apache-2.0
"""Wan2.2 prompt-embedding cache fingerprint regression tests."""
from __future__ import annotations
import importlib.util
import json
import os
import sys
from pathlib import Path
import numpy as np
import pytest
from fastvideo.mlx_runtime.prompt_cache import load_prompt_cache, save_prompt_cache
def _load_wan22_module():
root = Path(__file__).resolve().parents[3]
script = root / "examples/inference/basic/mlx_wan22_generate.py"
spec = importlib.util.spec_from_file_location(
"mlx_wan22_generate_for_test", script
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _load_wan21_module():
root = Path(__file__).resolve().parents[3]
script = root / "examples/inference/basic/mlx_wan_prompt_to_video.py"
spec = importlib.util.spec_from_file_location(
"mlx_wan_prompt_to_video_for_cache_test", script
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_wan22_prompt_cache_rejects_mismatched_fingerprint(tmp_path: Path) -> None:
module = _load_wan22_module()
cache_path = tmp_path / "prompt.npy"
text_encoder_root = tmp_path / "encoder"
text_encoder_root.mkdir()
fingerprint = module._prompt_cache_fingerprint(
prompt="a fox",
prompt_used="a fox, cinematic",
enhance_prompt=True,
enhance_prompt_backend="template",
text_encoder_root=text_encoder_root,
max_sequence_length=512,
dtype="fp16",
)
embeds = np.ones((1, 512, 4), dtype=np.float32)
save_prompt_cache(cache_path, embeds, fingerprint)
assert load_prompt_cache(cache_path, fingerprint) is not None
changed = dict(fingerprint)
changed["prompt"] = "a cat"
assert load_prompt_cache(cache_path, changed) is None
def test_wan22_prompt_cache_rejects_missing_metadata(tmp_path: Path) -> None:
module = _load_wan22_module()
cache_path = tmp_path / "prompt.npy"
np.save(cache_path, np.zeros((1, 512, 4), dtype=np.float32))
fingerprint = module._prompt_cache_fingerprint(
prompt="a fox",
prompt_used="a fox",
enhance_prompt=False,
enhance_prompt_backend="template",
text_encoder_root=tmp_path,
max_sequence_length=512,
dtype="fp16",
)
assert load_prompt_cache(cache_path, fingerprint) is None
def test_prompt_cache_is_best_effort_and_rejects_torn_data(tmp_path: Path) -> None:
cache_path = tmp_path / "prompt.npy"
fingerprint = {"prompt": "a fox"}
save_prompt_cache(cache_path, np.ones((1, 1, 1)), fingerprint)
np.save(cache_path, np.full((1, 1, 1), 9.0))
assert load_prompt_cache(cache_path, fingerprint) is None
invalid_parent = tmp_path / "not-a-directory"
invalid_parent.write_text("occupied")
assert not save_prompt_cache(
invalid_parent / "prompt.npy",
np.ones((1, 1, 1)),
fingerprint,
)
incomplete_path = tmp_path / "incomplete.npy"
incomplete = {"text_encoder": {"complete": False}}
assert not save_prompt_cache(incomplete_path, np.ones((1, 1, 1)), incomplete)
assert not incomplete_path.exists()
def test_wan21_explicit_cache_reencodes_for_a_changed_prompt(monkeypatch, tmp_path: Path) -> None:
import torch
module = _load_wan21_module()
calls = []
def fake_encode_prompt(**kwargs):
calls.append(kwargs["prompt"])
return torch.tensor([[float(len(calls))]])
monkeypatch.setattr(module, "encode_prompt", fake_encode_prompt)
cache_path = tmp_path / "prompt.npy"
kwargs = {
"model_root": tmp_path / "model",
"max_sequence_length": 512,
"device_arg": "cpu",
"dtype_arg": "fp16",
"encode_mode": "inline",
"cache_path": cache_path,
}
first = module.get_prompt_embeds(prompt="first", **kwargs)
second = module.get_prompt_embeds(prompt="second", **kwargs)
second_cached = module.get_prompt_embeds(prompt="second", **kwargs)
assert calls == ["first", "second"]
assert not torch.equal(first, second)
assert torch.equal(second, second_cached)
def test_wan22_missing_paths_download_only_selected_assets(monkeypatch, tmp_path: Path) -> None:
module = _load_wan22_module()
import huggingface_hub
calls = []
def fake_snapshot_download(model_id, allow_patterns):
calls.append((model_id, allow_patterns))
return str(tmp_path / ("wan21" if "2.1" in model_id else "wan22"))
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
text_root, dit_checkpoint, dit_config, vae_root = module._resolve_model_paths(
text_encoder_root=None,
dit_checkpoint=None,
dit_config=None,
vae_root=None,
mlx_checkpoint=None,
decode_backend="taehv",
)
assert text_root == tmp_path / "wan21"
assert dit_checkpoint == tmp_path / "wan22/transformer/diffusion_pytorch_model.safetensors"
assert dit_config == tmp_path / "wan22/transformer/config.json"
assert vae_root is None
assert calls == [
(module.FASTWAN21_MODEL_ID, ["tokenizer/*", "text_encoder/*"]),
(
module.FASTWAN22_MODEL_ID,
["transformer/diffusion_pytorch_model.safetensors", "transformer/config.json"],
),
]
def test_wan22_explicit_mlx_paths_do_not_download(monkeypatch, tmp_path: Path) -> None:
module = _load_wan22_module()
import huggingface_hub
def unexpected_download(*args, **kwargs):
pytest.fail("explicit MLX and text paths must not download model assets")
monkeypatch.setattr(huggingface_hub, "snapshot_download", unexpected_download)
text_root, dit_checkpoint, dit_config, vae_root = module._resolve_model_paths(
text_encoder_root=tmp_path / "text",
dit_checkpoint=None,
dit_config=None,
vae_root=None,
mlx_checkpoint=tmp_path / "mlx",
decode_backend="taehv",
)
assert text_root == tmp_path / "text"
assert dit_checkpoint is None
assert dit_config is None
assert vae_root is None
def test_wan22_prompt_cache_has_a_default_path(tmp_path: Path) -> None:
"""The cache must work without an explicit --prompt-embeds-cache.
It previously only engaged when handed a path, so every 5B run paid a full
UMT5 encode (~45s on an M4 Max) even for a repeat prompt, while the Wan2.1
entrypoint cached by default.
"""
module = _load_wan22_module()
fingerprint = module._prompt_cache_fingerprint(
prompt="a fox",
prompt_used="a fox",
enhance_prompt=False,
enhance_prompt_backend="none",
text_encoder_root=tmp_path,
max_sequence_length=512,
dtype="fp16",
)
path = module._default_prompt_cache_path(fingerprint)
assert path.suffix == ".npy"
assert path.parent.name == "prompt_embeds"
assert path.name.startswith("wan22_")
def test_wan22_default_cache_path_tracks_the_fingerprint(tmp_path: Path) -> None:
"""Two prompts must not collide, and the same prompt must be stable."""
module = _load_wan22_module()
def fp(prompt: str):
return module._prompt_cache_fingerprint(
prompt=prompt,
prompt_used=prompt,
enhance_prompt=False,
enhance_prompt_backend="none",
text_encoder_root=tmp_path,
max_sequence_length=512,
dtype="fp16",
)
assert module._default_prompt_cache_path(fp("a fox")) == module._default_prompt_cache_path(fp("a fox"))
assert module._default_prompt_cache_path(fp("a fox")) != module._default_prompt_cache_path(fp("a cat"))
def test_wan22_default_cache_path_tracks_encoder_files(tmp_path: Path) -> None:
module = _load_wan22_module()
weights = tmp_path / "text_encoder/weights.safetensors"
weights.parent.mkdir()
weights.write_bytes(b"one")
def path():
fingerprint = module._prompt_cache_fingerprint(
prompt="a fox",
prompt_used="a fox",
enhance_prompt=False,
enhance_prompt_backend="none",
text_encoder_root=tmp_path,
max_sequence_length=512,
dtype="fp16",
)
return module._default_prompt_cache_path(fingerprint)
before = path()
weights.write_bytes(b"two")
stat = weights.stat()
os.utime(weights, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1))
assert path() != before
@pytest.mark.parametrize(
("cache_args", "expected"),
[
([], "default"),
(["--no-prompt-cache"], None),
(["--prompt-embeds-cache", "explicit.npy"], "explicit"),
(["--no-prompt-cache", "--prompt-embeds-cache", "explicit.npy"], "explicit"),
],
)
def test_wan22_main_resolves_prompt_cache(
monkeypatch,
tmp_path: Path,
cache_args: list[str],
expected: str | None,
) -> None:
module = _load_wan22_module()
checkpoint = tmp_path / "mlx"
checkpoint.mkdir()
(checkpoint / "mlx_dit.json").write_text(
json.dumps({"config": {"patch_size": [1, 2, 2], "in_channels": 1}})
)
explicit = tmp_path / "explicit.npy"
class CacheProbe(Exception):
pass
def probe(cache_path, fingerprint):
if expected == "default":
assert cache_path == module._default_prompt_cache_path(fingerprint)
elif expected == "explicit":
assert cache_path == explicit
else:
assert cache_path is None
raise CacheProbe
resolved_args = [str(explicit) if arg == "explicit.npy" else arg for arg in cache_args]
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setattr(module, "load_prompt_cache", probe)
monkeypatch.setattr(
sys,
"argv",
[
"mlx_wan22_generate.py",
"--text-encoder-root",
str(tmp_path / "text"),
"--mlx-checkpoint",
str(checkpoint),
"--height",
"32",
"--width",
"32",
"--num-frames",
"1",
*resolved_args,
],
)
with pytest.raises(CacheProbe):
module.main()
@@ -0,0 +1,222 @@
# SPDX-License-Identifier: Apache-2.0
"""Track D Rung 3: real-weight T2V parity for Wan2.2-TI2V-5B MLX vs torch.
Loads the released ``FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers`` transformer
into both ``MLXWan22DiT`` and torch ``WanTransformer3DModel``, runs one forward
with a 2-D per-token timestep (frame 0 at t=0 I2V-style, remaining tokens at
t≈900), and asserts allclose. Gated on Metal + local weights so Linux
``mlx[cpu]`` CI stays green.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import numpy as np
import pytest
# The torch reference side brings up FastVideo's single-process distributed
# environment, which rendezvouses over env://. Match the other torch-backed
# tests in this repo and provide a local rendezvous before that happens.
os.environ.setdefault("MASTER_ADDR", "localhost")
os.environ.setdefault("MASTER_PORT", "29513")
mx = pytest.importorskip("mlx.core", reason="MLX is required for the Wan2.2 real-weight parity test")
_ROOT = Path(os.environ.get("FASTVIDEO_WAN22_5B_ROOT", str(Path.home() / "models" / "fastwan22_5b")))
_CHECKPOINT = _ROOT / "transformer" / "diffusion_pytorch_model.safetensors"
_CONFIG = _ROOT / "transformer" / "config.json"
_HAS_METAL = bool(getattr(mx, "metal", None) and mx.metal.is_available())
_HAS_WEIGHTS = _CHECKPOINT.exists() and _CONFIG.exists() and _CHECKPOINT.stat().st_size > 1_000_000_000
# The real-weight test loads ~10 GB fp16 weights + activations. Gate on the
# documented 36 GB requirement unless explicitly opted in.
def _check_memory_requirement() -> bool:
"""Determine whether the system meets the memory requirement for the test.
Returns:
bool: `true` if the system has at least 36 GiB of memory or low-memory
execution is explicitly enabled, `false` otherwise.
"""
if os.environ.get("FASTVIDEO_WAN22_5B_ALLOW_LOW_MEMORY") == "1":
return True
try:
# On macOS, check unified memory via sysctl.
import subprocess
result = subprocess.run(
["sysctl", "-n", "hw.memsize"],
capture_output=True,
text=True,
check=False,
timeout=5,
)
if result.returncode == 0:
mem_bytes = int(result.stdout.strip())
mem_gib = mem_bytes / (1024**3)
return mem_gib >= 36.0
except Exception:
pass
# Conservative fallback: require opt-in if we can't detect memory.
return False
_HAS_SUFFICIENT_MEMORY = _check_memory_requirement()
pytestmark = [
pytest.mark.skipif(not _HAS_METAL, reason="Metal required for real 5B fp16 parity"),
pytest.mark.skipif(
not _HAS_WEIGHTS,
reason=f"Wan2.2-5B checkpoint not found/incomplete under {_ROOT} "
"(set FASTVIDEO_WAN22_5B_ROOT; expected ~10 GB safetensors)",
),
pytest.mark.skipif(
not _HAS_SUFFICIENT_MEMORY,
reason="Real-weight 5B test requires >= 36 GB unified memory "
"(set FASTVIDEO_WAN22_5B_ALLOW_LOW_MEMORY=1 to override)",
),
]
def _load_torch_wan22_from_diffusers(checkpoint: Path, config_path: Path, *, dtype):
"""
Instantiate a Wan2.2 transformer and load its Diffusers-format checkpoint weights.
Parameters:
checkpoint (Path): Path to the safetensors checkpoint.
config_path (Path): Path to the Diffusers configuration file.
dtype: Data type to use for the returned model.
Returns:
WanTransformer3DModel: An evaluation-mode model with the loaded weights.
"""
import torch
from safetensors.torch import load_file
from fastvideo.configs.models.dits.wanvideo import WanVideoArchConfig, WanVideoConfig
from fastvideo.models.dits.wanvideo import WanTransformer3DModel
from fastvideo.models.loader.utils import get_param_names_mapping, hf_to_custom_state_dict
hf_config = json.loads(config_path.read_text())
# Diffusers-only keys that are not arch fields.
arch_kwargs = {
k: v
for k, v in hf_config.items()
if k not in {"_class_name", "_name_or_path", "_diffusers_version"}
}
cfg = WanVideoConfig(arch_config=WanVideoArchConfig(**{
"num_attention_heads": int(arch_kwargs["num_attention_heads"]),
"attention_head_dim": int(arch_kwargs["attention_head_dim"]),
"in_channels": int(arch_kwargs["in_channels"]),
"out_channels": int(arch_kwargs["out_channels"]),
"text_dim": int(arch_kwargs["text_dim"]),
"freq_dim": int(arch_kwargs["freq_dim"]),
"ffn_dim": int(arch_kwargs["ffn_dim"]),
"num_layers": int(arch_kwargs["num_layers"]),
"patch_size": tuple(arch_kwargs["patch_size"]),
"cross_attn_norm": bool(arch_kwargs.get("cross_attn_norm", True)),
"qk_norm": arch_kwargs.get("qk_norm", "rms_norm_across_heads"),
"eps": float(arch_kwargs.get("eps", 1e-6)),
"rope_max_seq_len": int(arch_kwargs.get("rope_max_seq_len", 1024)),
"added_kv_proj_dim": arch_kwargs.get("added_kv_proj_dim"),
"image_dim": arch_kwargs.get("image_dim"),
"pos_embed_seq_len": arch_kwargs.get("pos_embed_seq_len"),
}))
model = WanTransformer3DModel(config=cfg, hf_config=hf_config).eval()
raw = load_file(str(checkpoint), device="cpu")
mapping = get_param_names_mapping(model.param_names_mapping)
custom_sd, _ = hf_to_custom_state_dict(raw.items(), mapping)
missing, unexpected = model.load_state_dict(custom_sd, strict=False)
# Diffusers layout should map cleanly; unexpected keys are always an error.
assert not unexpected, f"unexpected keys: {unexpected[:8]}"
# Missing keys must be either empty buffers or explicitly approved. Reject
# missing parameters to prevent parity checks from running with uninitialized weights.
if missing:
# Allowlist for known empty buffers (if any exist for Wan2.2-5B).
# Update this list if legitimate empty buffers are identified.
allowed_missing = set()
unapproved = [k for k in missing if k not in allowed_missing]
assert not unapproved, (
f"Missing keys in Wan2.2-5B state dict load: {unapproved[:8]}. "
"Cannot proceed with parity check; uninitialized parameters would "
"invalidate results."
)
return model.to(dtype=dtype)
@pytest.mark.usefixtures("distributed_setup")
def test_wan22_real_weights_mlx_matches_torch_per_token_timestep() -> None:
"""
Verify that the MLX and PyTorch Wan2.2 implementations produce equivalent outputs for per-token timesteps using real checkpoint weights.
"""
import torch
from fastvideo.forward_context import set_forward_context
from fastvideo.mlx_runtime.wan22 import mlx_wan22_dit_from_diffusers_safetensors
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
from examples.inference.basic.mlx_wan_prompt_to_video import make_rotary_embeddings
config = json.loads(_CONFIG.read_text())
in_ch = int(config["in_channels"])
text_dim = int(config["text_dim"])
# Small latent so activations fit alongside the ~10 GB fp16 weights on 36 GB.
frames, height, width = 2, 16, 16
p_t, p_h, p_w = tuple(config["patch_size"])
tokens_per_frame = (height // p_h) * (width // p_w)
num_tokens = (frames // p_t) * tokens_per_frame
# Frame 0 clean (t=0), remaining tokens noised (t=900) — I2V-style expand_timesteps.
per_frame_levels = [0] + [900] * (frames // p_t - 1)
timestep_1d = [per_frame_levels[i // tokens_per_frame] for i in range(num_tokens)]
timestep = torch.tensor([timestep_1d], dtype=torch.long)
rng = np.random.default_rng(2026)
hidden_np = (rng.standard_normal((1, in_ch, frames, height, width)) * 0.5).astype(np.float32)
text_np = (rng.standard_normal((1, 32, text_dim)) * 0.1).astype(np.float32)
# --- torch reference (CPU fp16, same weight dtype as MLX deploy path) ---
torch_model = _load_torch_wan22_from_diffusers(_CHECKPOINT, _CONFIG, dtype=torch.float16)
hidden_t = torch.from_numpy(hidden_np).to(torch.float16)
text_t = torch.from_numpy(text_np).to(torch.float16)
with torch.no_grad(), set_forward_context(
current_timestep=0, attn_metadata=None, forward_batch=ForwardBatch(data_type="dummy")):
ref = torch_model(hidden_states=hidden_t, encoder_hidden_states=text_t, timestep=timestep)
ref_np = ref.detach().float().cpu().numpy()
# Free torch weights before loading MLX so ~10 GB fp16 + activations fit in 36 GB.
del torch_model
import gc
gc.collect()
# --- MLX (fp16 weights, fp16 compute) ---
mlx_model = mlx_wan22_dit_from_diffusers_safetensors(_CHECKPOINT, _CONFIG, dtype="fp16")
freqs_cis = make_rotary_embeddings(
config, latent_frames=frames, latent_height=height, latent_width=width)
out = mlx_model(
mx.array(hidden_np).astype(mx.float16),
mx.array(text_np).astype(mx.float16),
mx.array(np.array(timestep_1d, dtype=np.float32)[None, :]),
freqs_cis,
)
mx.eval(out)
mlx_np = np.array(out.astype(mx.float32))
assert mlx_np.shape == ref_np.shape
assert np.isfinite(mlx_np).all()
max_abs = float(np.abs(mlx_np - ref_np).max())
mean_abs = float(np.abs(mlx_np - ref_np).mean())
flat_a, flat_b = mlx_np.ravel(), ref_np.ravel()
cosine = float(np.dot(flat_a, flat_b) / (np.linalg.norm(flat_a) * np.linalg.norm(flat_b) + 1e-12))
print(f"Wan2.2-5B real-weight MLX-fp16 vs torch-fp16: "
f"max|Δ|={max_abs:.3e} mean|Δ|={mean_abs:.3e} cosine={cosine:.6f}")
# Full 30-layer fp16 Metal vs torch SDPA drifts more than the tiny-config
# fp32 gate (2e-3) / CUDA-tiny dump (5e-3). Measured on M4 Max: mean 6.1e-3,
# cosine 0.999948, both stable across MLX versions. The worst single element
# is not: it was 9e-2 when this budget was written and reaches 1.65e-1 under
# MLX 0.32's kernel fusion, so the aggregate statistics carry the assertion
# and max|Δ| stays as a loose guard against a genuine divergence.
assert mean_abs <= 1e-2, f"mean|Δ|={mean_abs} over budget"
assert cosine >= 0.9995, f"cosine={cosine} too low"
assert max_abs <= 0.25, f"max|Δ|={max_abs} over budget"
@@ -0,0 +1,60 @@
# SPDX-License-Identifier: Apache-2.0
"""Wan VAE latent denormalization regression tests."""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
from fastvideo.mlx_runtime.wan_vae import (
WanVAEConfigView,
denormalize_latents_np,
)
def test_denormalize_matches_prompt_to_video_formula() -> None:
"""``latents / (1/std) + mean`` used by the hybrid script."""
cfg = WanVAEConfigView(
z_dim=2,
latents_mean=(0.1, -0.2),
latents_std=(2.0, 0.5),
)
lat = np.ones((1, 2, 1, 1, 1), dtype=np.float32)
out = denormalize_latents_np(lat, cfg)
# z * std + mean
np.testing.assert_allclose(out[0, 0, 0, 0, 0], 1.0 * 2.0 + 0.1)
np.testing.assert_allclose(out[0, 1, 0, 0, 0], 1.0 * 0.5 + (-0.2))
def test_full_vae_config_and_denormalize_formula() -> None:
"""Config load + denormalize formula vs the hybrid script's tensor math."""
import torch
snaps = list(
Path.home()
.joinpath(
".cache/huggingface/hub/"
"models--FastVideo--FastWan2.2-TI2V-5B-FullAttn-Diffusers/"
"snapshots"
)
.glob("*/vae")
)
if not snaps:
pytest.skip("Wan2.2 VAE not in HF cache")
vae_dir = snaps[0]
cfg = WanVAEConfigView.from_vae_dir(vae_dir)
assert cfg.z_dim == 48
rng = np.random.default_rng(1)
lat = (rng.standard_normal((1, 48, 3, 8, 8)) * 0.3).astype(np.float32)
lat_dn = denormalize_latents_np(lat, cfg)
mean = torch.tensor(cfg.latents_mean, dtype=torch.float32).view(
1, -1, 1, 1, 1
)
inv_std = (1.0 / torch.tensor(cfg.latents_std, dtype=torch.float32)).view(
1, -1, 1, 1, 1
)
lat_t = torch.from_numpy(lat)
ref = (lat_t / inv_std + mean).numpy()
np.testing.assert_allclose(lat_dn, ref, atol=1e-5, rtol=1e-5)
+126
View File
@@ -0,0 +1,126 @@
# SPDX-License-Identifier: Apache-2.0
"""Accuracy + capability probe for MLX block-scaled quant backends.
For each backend in :mod:`fastvideo.mlx_runtime.quant_backends`, if the installed
MLX build supports the mode, assert that quantized matmul stays within a
per-format relative Frobenius error budget vs. the fp16 reference ``x @ w.T``.
Unsupported modes are skipped with an explicit reason.
Run with ``-s`` to print the summary table (backend | supported | bytes/weight |
rel-error).
"""
from __future__ import annotations
import pytest
pytest.importorskip("mlx.core", reason="MLX is required for quant backend tests")
import mlx.core as mx # noqa: E402
from fastvideo.mlx_runtime.quant_backends import ( # noqa: E402
BACKENDS,
bytes_per_weight,
is_supported,
quantize_weight,
quantized_matmul,
support_error,
)
# Fixed shapes: weight cols must be divisible by all group sizes (64/32/16).
_WEIGHT_ROWS = 1024
_WEIGHT_COLS = 1024
_BATCH = 8
_SEED = 0
# Relative Frobenius error ||y - ref||_F / ||ref||_F tolerances.
# Measured once on mlx 0.31.2 / Apple Silicon for N(0,1) fp16 weights:
# affine_int8_g64 ~ 5e-3, mxfp8 ~ 8e-2, mxfp4 ~ 1.2e-1, nvfp4 ~ 1.0e-1.
# Budgets keep ~2x headroom for RNG / platform variance.
_REL_ERROR_TOL: dict[str, float] = {
"affine_int8_g64": 2e-2,
"mxfp8": 1.5e-1,
"mxfp4": 2.5e-1,
"nvfp4": 2.5e-1,
}
def _frobenius_rel_error(y: mx.array, ref: mx.array) -> float:
"""||y - ref||_F / ||ref||_F in float32 (avoids fp16 sum overflow)."""
y32 = y.astype(mx.float32)
ref32 = ref.astype(mx.float32)
diff = y32 - ref32
num = mx.sqrt(mx.sum(diff * diff))
den = mx.sqrt(mx.sum(ref32 * ref32)) + mx.array(1e-12, dtype=mx.float32)
mx.eval(num, den)
return float(num / den)
def _make_inputs() -> tuple[mx.array, mx.array, mx.array]:
"""
Generate deterministic random weights and inputs with their matrix multiplication reference.
Returns:
tuple[mx.array, mx.array, mx.array]: The weights, inputs, and fp16 reference output.
"""
mx.random.seed(_SEED)
w = mx.random.normal((_WEIGHT_ROWS, _WEIGHT_COLS)).astype(mx.float16)
x = mx.random.normal((_BATCH, _WEIGHT_COLS)).astype(mx.float16)
ref = x @ w.T
mx.eval(w, x, ref)
return w, x, ref
@pytest.mark.parametrize("backend", list(BACKENDS))
def test_quant_backend_matmul_accuracy(backend: str) -> None:
if not is_supported(backend):
err = support_error(backend) or "unknown error"
pytest.skip(reason=f"{backend} not supported by installed MLX: {err}")
w, x, ref = _make_inputs()
qw = quantize_weight(w, backend)
y = quantized_matmul(x, qw)
mx.eval(y)
rel = _frobenius_rel_error(y, ref)
tol = _REL_ERROR_TOL[backend]
assert rel < tol, (
f"{backend}: relative Frobenius error {rel:.6e} exceeds tolerance {tol:.6e}"
)
def test_quant_backends_summary_table(capsys: pytest.CaptureFixture[str]) -> None:
"""
Print a support, storage, and relative-error summary for each quantization backend.
The table reports unsupported backends as unavailable and asserts that the affine
int8 baseline is supported.
"""
w, x, ref = _make_inputs()
rows: list[tuple[str, str, str, str]] = []
for backend in BACKENDS:
supported = is_supported(backend)
if not supported:
rows.append((backend, "no", "n/a", "n/a"))
continue
bpw = bytes_per_weight(backend)
qw = quantize_weight(w, backend)
y = quantized_matmul(x, qw)
mx.eval(y)
rel = _frobenius_rel_error(y, ref)
rows.append((backend, "yes", f"{bpw:.6f}", f"{rel:.6e}"))
# Header + rows for human inspection under pytest -s.
col_w = max(len(r[0]) for r in rows)
header = f"{'backend':<{col_w}} supported bytes/weight rel-error"
print("\n" + header)
print("-" * len(header))
for backend, supported, bpw, rel in rows:
print(f"{backend:<{col_w}} {supported:<9} {bpw:>12} {rel}")
# At least the affine baseline must work on any supported MLX install.
assert is_supported("affine_int8_g64"), (
"affine_int8_g64 must be supported; cannot evaluate other backends"
)
@@ -0,0 +1,72 @@
# SPDX-License-Identifier: Apache-2.0
"""MLX quantization-mode capability detection.
The affine int8/int4 modes work on every MLX build the repo supports; the
mxfp8/mxfp4/nvfp4 mode strings only exist on newer MLX builds. These tests pin
the contract: supported modes probe clean, and unsupported ones surface as
``UnsupportedMLXQuantizationError`` with an actionable message *before* any
model weights are loaded.
Runs on any MLX backend (Metal or ``mlx[cpu]``).
"""
from __future__ import annotations
import pytest
pytest.importorskip("mlx.core", reason="MLX is required for quantization capability tests")
from fastvideo.mlx_runtime.fastwan import ( # noqa: E402
MLXQuantizationSpec,
UnsupportedMLXQuantizationError,
_QUANT_SUPPORT_CACHE,
ensure_quantization_supported,
quantization_support_error,
)
def test_affine_modes_are_supported_on_any_mlx_build() -> None:
for name in ("int8", "int4"):
spec = MLXQuantizationSpec.from_name(name)
assert quantization_support_error(spec) is None, f"{name} must be supported on every MLX build"
ensure_quantization_supported(spec) # must not raise
def test_none_spec_is_always_supported() -> None:
ensure_quantization_supported(None) # must not raise
def test_probe_result_is_cached_per_spec() -> None:
spec = MLXQuantizationSpec.from_name("int8")
quantization_support_error(spec)
key = (spec.mode, spec.bits, spec.group_size)
assert key in _QUANT_SUPPORT_CACHE
def test_unsupported_mode_raises_with_actionable_message(monkeypatch) -> None:
spec = MLXQuantizationSpec.from_name("nvfp4")
key = (spec.mode, spec.bits, spec.group_size)
monkeypatch.setitem(_QUANT_SUPPORT_CACHE, key, "ValueError: [quantize] Unknown mode 'nvfp4'")
with pytest.raises(UnsupportedMLXQuantizationError) as excinfo:
ensure_quantization_supported(spec)
message = str(excinfo.value)
assert "nvfp4" in message
assert "int8" in message # points the user at the reliable fallback
def test_loader_rejects_unsupported_mode_before_reading_weights(monkeypatch, tmp_path) -> None:
"""Verify that unsupported quantization modes are rejected before checkpoint weights are read."""
from fastvideo.mlx_runtime.fastwan import mlx_dit_from_diffusers_safetensors
spec_key = ("mxfp4", None, None)
monkeypatch.setitem(_QUANT_SUPPORT_CACHE, spec_key, "ValueError: [quantize] Unknown mode 'mxfp4'")
config_path = tmp_path / "config.json"
config_path.write_text('{"num_layers": 1, "num_attention_heads": 4, "attention_head_dim": 16, '
'"ffn_dim": 128, "in_channels": 16, "out_channels": 16, '
'"patch_size": [1, 2, 2], "freq_dim": 64, "eps": 1e-6}')
# The checkpoint path deliberately does not exist: the capability check
# must fire before the loader ever opens the safetensors file.
with pytest.raises(UnsupportedMLXQuantizationError):
mlx_dit_from_diffusers_safetensors(tmp_path / "missing.safetensors", config_path, quantization="mxfp4")
+34
View File
@@ -0,0 +1,34 @@
# SPDX-License-Identifier: Apache-2.0
"""Offline contracts for the vendored TAEHV decoder helper."""
import re
import pytest
from fastvideo.mlx_runtime import wan_vae
from fastvideo.mlx_runtime.taehv_decode import TAEW2_1_CHECKPOINT_SHA256, ensure_taew2_1_checkpoint
def test_taehv_checkpoint_pin_is_a_sha256() -> None:
assert len(TAEW2_1_CHECKPOINT_SHA256) == 64
assert int(TAEW2_1_CHECKPOINT_SHA256, 16) >= 0
def test_explicit_taehv_checkpoint_path_is_not_downloaded(tmp_path) -> None:
checkpoint = tmp_path / "custom-taehv.pth"
checkpoint.write_bytes(b"local test checkpoint")
assert ensure_taew2_1_checkpoint(checkpoint) == checkpoint
def test_taew2_2_checkpoint_pin_is_immutable_and_sha256() -> None:
assert "/main/" not in wan_vae.TAEW2_2_URL
assert re.search(r"/[0-9a-f]{40}/taew2_2\.pth$", wan_vae.TAEW2_2_URL)
assert re.fullmatch(r"[0-9a-f]{64}", wan_vae.TAEW2_2_SHA256)
def test_cached_taew2_2_checkpoint_is_verified(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(wan_vae, "_cache_dir", lambda: tmp_path)
(tmp_path / "taew2_2.pth").write_bytes(b"tampered")
with pytest.raises(RuntimeError, match="failed sha256 verification"):
wan_vae.ensure_taehv_checkpoint(z_dim=48)
+32
View File
@@ -0,0 +1,32 @@
# SPDX-License-Identifier: Apache-2.0
"""Unit tests for Wan2.2 warped DMD schedule construction."""
from __future__ import annotations
import torch
from fastvideo.models.schedulers.scheduling_flow_match_euler_discrete import FlowMatchEulerDiscreteScheduler
from fastvideo.mlx_runtime.wan22_sample import build_wan22_dmd_schedule
def test_warped_dmd_timesteps_differ_from_raw_indices() -> None:
schedule, warped = build_wan22_dmd_schedule([1000, 757, 522], flow_shift=5.0, warp_denoising_step=True)
_, raw = build_wan22_dmd_schedule([1000, 757, 522], flow_shift=5.0, warp_denoising_step=False)
assert raw == [1000.0, 757.0, 522.0]
# Warping maps step indices into the continuous flow-match schedule.
assert warped[0] == 1000.0
assert warped[1] != 757.0
assert warped[2] != 522.0
# Sigmas are monotone-ish decreasing along the schedule.
sigmas = [schedule.sigma_for(t) for t in warped]
assert sigmas[0] >= sigmas[1] >= sigmas[2]
def test_warped_dmd_timesteps_match_training_scheduler() -> None:
steps = torch.tensor([1000, 757, 522], dtype=torch.long)
scheduler = FlowMatchEulerDiscreteScheduler(shift=5.0)
expected = torch.cat((scheduler.timesteps, torch.tensor([0.0])))[1000 - steps]
_, warped = build_wan22_dmd_schedule(steps.tolist(), flow_shift=5.0, warp_denoising_step=True)
torch.testing.assert_close(torch.tensor(warped), expected)
@@ -0,0 +1,118 @@
# SPDX-License-Identifier: Apache-2.0
"""Correctness checks for chunked non-causal windowed attention (MLX)."""
from __future__ import annotations
import numpy as np
import pytest
mx = pytest.importorskip("mlx.core", reason="MLX is required for windowed-attention tests")
from fastvideo.mlx_runtime.windowed_attention import ( # noqa: E402
_key_ranges_for_block,
full_attention,
windowed_attention,
)
def _rand_qkv(b: int, h: int, s: int, d: int, seed: int = 0):
"""
Generate deterministic random query, key, and value tensors.
Parameters:
b (int): Batch size.
h (int): Number of attention heads.
s (int): Sequence length.
d (int): Feature dimension.
seed (int): Random number generator seed.
Returns:
tuple: Query, key, and value tensors with shape `(b, h, s, d)`.
"""
rng = np.random.default_rng(seed)
q = mx.array(rng.standard_normal((b, h, s, d)).astype(np.float32))
k = mx.array(rng.standard_normal((b, h, s, d)).astype(np.float32))
v = mx.array(rng.standard_normal((b, h, s, d)).astype(np.float32))
return q, k, v
def _mean_cosine(a: "mx.array", b: "mx.array") -> float:
"""
Compute the mean cosine similarity between corresponding feature vectors.
Parameters:
a: The first tensor.
b: The second tensor.
Returns:
The mean cosine similarity, with protection against zero-norm vectors.
"""
a32 = np.array(a.astype(mx.float32)).reshape(-1, a.shape[-1])
b32 = np.array(b.astype(mx.float32)).reshape(-1, b.shape[-1])
num = np.sum(a32 * b32, axis=-1)
den = np.linalg.norm(a32, axis=-1) * np.linalg.norm(b32, axis=-1)
return float(np.mean(num / np.maximum(den, 1e-12)))
def test_windowed_matches_full_when_window_ge_seq() -> None:
"""``window >= S`` must recover full attention (cosine > 0.999)."""
b, h, s, d = 1, 2, 64, 16
q, k, v = _rand_qkv(b, h, s, d, seed=7)
scale = d**-0.5
ref = full_attention(q, k, v, scale=scale)
out = windowed_attention(q, k, v, window=s, sink=0, scale=scale)
mx.eval(ref, out)
assert out.shape == q.shape
cos = _mean_cosine(ref, out)
assert cos > 0.999, f"expected cosine > 0.999 for window>=S, got {cos}"
def test_windowed_output_shape_equals_input() -> None:
b, h, s, d = 2, 3, 48, 8
q, k, v = _rand_qkv(b, h, s, d, seed=11)
out = windowed_attention(q, k, v, window=16, sink=4)
mx.eval(out)
assert out.shape == q.shape
assert out.shape == (b, h, s, d)
def test_windowed_with_sinks_shape_and_finite() -> None:
b, h, s, d = 1, 2, 40, 8
q, k, v = _rand_qkv(b, h, s, d, seed=19)
out = windowed_attention(q, k, v, window=8, sink=4, chunk_size=5)
mx.eval(out)
assert out.shape == (b, h, s, d)
arr = np.array(out)
assert np.isfinite(arr).all()
def test_windowed_result_does_not_depend_on_chunk_size() -> None:
q, k, v = _rand_qkv(1, 2, 40, 8, seed=23)
per_query = windowed_attention(q, k, v, window=8, sink=4, chunk_size=1)
chunked = windowed_attention(q, k, v, window=8, sink=4, chunk_size=5)
mx.eval(per_query, chunked)
np.testing.assert_allclose(np.array(chunked), np.array(per_query), rtol=1e-5, atol=1e-5)
def test_sink_range_extends_beyond_local_window() -> None:
assert _key_ranges_for_block(qs=0, qe=1, seq_len=12, half=1, sink=5) == [(0, 5)]
def test_windowed_attention_enforces_per_query_local_and_sink_mask() -> None:
q = mx.zeros((1, 1, 6, 1))
k = mx.zeros_like(q)
v = mx.arange(6).reshape(1, 1, 6, 1)
out = windowed_attention(q, k, v, window=2, sink=3, chunk_size=4)
mx.eval(out)
np.testing.assert_allclose(np.array(out).reshape(-1), [1.0, 1.0, 1.5, 2.0, 2.5, 2.4], atol=1e-6)
def test_invalid_window_raises() -> None:
q, k, v = _rand_qkv(1, 1, 8, 4, seed=1)
with pytest.raises(ValueError, match="window"):
windowed_attention(q, k, v, window=0)
+290
View File
@@ -0,0 +1,290 @@
# SPDX-License-Identifier: Apache-2.0
"""Tiny random-weight Wan DiT fixtures shared by the MLX runtime tests.
Builds a miniature ``WanTransformer3DModel`` (all matmul dims are multiples of
the int8 group size 64, so the same model exercises the quantized paths) plus
the conversions into ``fastvideo.mlx_runtime.fastwan.MLXWanDiT``.
"""
from __future__ import annotations
import os
import numpy as np
import torch
os.environ.setdefault("FASTVIDEO_ATTENTION_BACKEND", "TORCH_SDPA")
os.environ.setdefault("MASTER_ADDR", "localhost")
os.environ.setdefault("MASTER_PORT", "29513")
from fastvideo.configs.models.dits.wanvideo import ( # noqa: E402
WanVideoArchConfig,
WanVideoConfig,
)
from fastvideo.forward_context import set_forward_context # noqa: E402
from fastvideo.layers.rotary_embedding import get_rotary_pos_embed # noqa: E402
from fastvideo.mlx_runtime.fastwan import ( # noqa: E402
MLXQuantizationSpec,
MLXWanDiT,
MLXWanTransformerBlock,
mlx_block_weights_from_torch,
quantize_matrix,
)
from fastvideo.models.dits.wanvideo import WanTransformer3DModel # noqa: E402
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch # noqa: E402
SEED = 2026
# All matmul dims must be multiples of the int8 group size (64) so the
# quantized variants of the tests can reuse the same tiny model.
TINY_ARCH = dict(
num_attention_heads=4,
attention_head_dim=16,
in_channels=16,
out_channels=16,
text_dim=64,
freq_dim=64,
ffn_dim=128,
num_layers=2,
patch_size=(1, 2, 2),
rope_max_seq_len=64,
)
def build_tiny_wan_config() -> WanVideoConfig:
"""Build the deterministic miniature Wan video configuration.
Returns:
WanVideoConfig: Configuration created from the tiny test architecture.
"""
return WanVideoConfig(arch_config=WanVideoArchConfig(**TINY_ARCH))
def build_hf_config(config: WanVideoConfig) -> dict[str, object]:
"""
Builds the configuration dictionary required by the Hugging Face-style Wan model representation.
Parameters:
config (WanVideoConfig): Wan video configuration to convert.
Returns:
dict[str, object]: Configuration values for the Hugging Face-style model.
"""
return {
"num_attention_heads": config.num_attention_heads,
"attention_head_dim": config.attention_head_dim,
"in_channels": config.in_channels,
"out_channels": config.out_channels,
"text_dim": config.text_dim,
"freq_dim": config.freq_dim,
"ffn_dim": config.ffn_dim,
"num_layers": config.num_layers,
"patch_size": config.patch_size,
"text_len": config.text_len,
"rope_max_seq_len": config.rope_max_seq_len,
"eps": 1e-6,
}
def initialize_model_parameters(model: torch.nn.Module) -> None:
# ReplicatedLinear parameters are allocated with torch.empty and need an
# explicit initialization in tests to avoid undefined values.
"""Initialize model parameters deterministically for reproducible tests.
Parameters:
model (torch.nn.Module): Model whose parameters will be initialized.
"""
torch.manual_seed(SEED + 3)
with torch.no_grad():
for name, param in model.named_parameters():
if param.ndim <= 1:
if name.endswith("weight") and "norm" in name:
param.fill_(1.0)
else:
param.normal_(mean=0.0, std=0.02)
continue
torch.nn.init.xavier_uniform_(param)
def build_torch_model() -> WanTransformer3DModel:
"""
Build a deterministically initialized CPU float32 Wan transformer model.
Returns:
WanTransformer3DModel: The initialized model in evaluation mode.
"""
config = build_tiny_wan_config()
model = WanTransformer3DModel(config=config, hf_config=build_hf_config(config))
model = model.to(device="cpu", dtype=torch.float32)
initialize_model_parameters(model)
model.eval()
return model
def build_inputs() -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Create deterministic latent, text-encoder, and timestep inputs for the tiny Wan model.
Returns:
tuple[torch.Tensor, torch.Tensor, torch.Tensor]: The latent hidden states, text-encoder hidden states, and timestep."""
generator = torch.Generator(device="cpu").manual_seed(SEED + 1)
hidden_states = torch.randn(1, TINY_ARCH["in_channels"], 4, 8, 8, generator=generator, dtype=torch.float32)
encoder_hidden_states = torch.randn(1, 8, TINY_ARCH["text_dim"], generator=generator, dtype=torch.float32)
timestep = torch.tensor([10], dtype=torch.long)
return hidden_states, encoder_hidden_states, timestep
# Top-level weights: the MLX runtime uses the Diffusers key layout; the torch
# model uses FastVideo's module names (see WanVideoConfig.param_names_mapping).
TOP_LEVEL_KEY_MAP = {
"patch_embedding.weight": "patch_embedding.proj.weight",
"patch_embedding.bias": "patch_embedding.proj.bias",
"condition_embedder.time_embedder.linear_1.weight": "condition_embedder.time_embedder.mlp.fc_in.weight",
"condition_embedder.time_embedder.linear_1.bias": "condition_embedder.time_embedder.mlp.fc_in.bias",
"condition_embedder.time_embedder.linear_2.weight": "condition_embedder.time_embedder.mlp.fc_out.weight",
"condition_embedder.time_embedder.linear_2.bias": "condition_embedder.time_embedder.mlp.fc_out.bias",
"condition_embedder.time_proj.weight": "condition_embedder.time_modulation.linear.weight",
"condition_embedder.time_proj.bias": "condition_embedder.time_modulation.linear.bias",
"condition_embedder.text_embedder.linear_1.weight": "condition_embedder.text_embedder.fc_in.weight",
"condition_embedder.text_embedder.linear_1.bias": "condition_embedder.text_embedder.fc_in.bias",
"condition_embedder.text_embedder.linear_2.weight": "condition_embedder.text_embedder.fc_out.weight",
"condition_embedder.text_embedder.linear_2.bias": "condition_embedder.text_embedder.fc_out.bias",
"scale_shift_table": "scale_shift_table",
"proj_out.weight": "proj_out.weight",
"proj_out.bias": "proj_out.bias",
}
def mlx_dit_from_torch_model(
model: WanTransformer3DModel,
hf_config: dict[str, object],
*,
quantization: MLXQuantizationSpec | None = None,
) -> MLXWanDiT:
"""
Convert a PyTorch Wan DiT model to an MLX Wan DiT model.
Parameters:
model (WanTransformer3DModel): PyTorch model whose parameters are converted.
hf_config (dict[str, object]): Model configuration used to construct the MLX representation.
quantization (MLXQuantizationSpec | None): Optional quantization specification for eligible weight matrices.
Returns:
MLXWanDiT: The converted MLX model.
"""
import mlx.core as mx
state = {name: value.detach().float() for name, value in model.state_dict().items()}
inner_dim = int(hf_config["num_attention_heads"]) * int(hf_config["attention_head_dim"]) # type: ignore[arg-type]
weights = {}
for mlx_name, torch_name in TOP_LEVEL_KEY_MAP.items():
tensor = state[torch_name]
if mlx_name == "patch_embedding.weight":
tensor = tensor.reshape(inner_dim, -1)
array = mx.array(tensor.numpy())
if quantization is not None and mlx_name.endswith(".weight") and mlx_name != "scale_shift_table":
weights[mlx_name] = quantize_matrix(array, quantization)
else:
weights[mlx_name] = array
blocks = []
for torch_block in model.blocks:
block_weights = mlx_block_weights_from_torch(torch_block)
if quantization is not None:
block_weights = {
name: (quantize_matrix(value, quantization)
if name.endswith(".weight") and "norm" not in name and len(value.shape) >= 2 else value)
for name, value in block_weights.items()
}
blocks.append(
MLXWanTransformerBlock(
block_weights,
dim=inner_dim,
ffn_dim=int(hf_config["ffn_dim"]), # type: ignore[arg-type]
num_heads=int(hf_config["num_attention_heads"]), # type: ignore[arg-type]
eps=float(hf_config["eps"]), # type: ignore[arg-type]
))
return MLXWanDiT(weights, blocks, dict(hf_config))
def mlx_rotary_embeddings(hidden_states: torch.Tensor):
"""
Generate MLX rotary-position embedding tables matching the tiny Wan model.
Parameters:
hidden_states (torch.Tensor): Input latent tensor whose spatial and temporal dimensions determine the embedding grid.
Returns:
Tuple of MLX arrays containing the cosine and sine rotary-position embeddings.
"""
import mlx.core as mx
_, _, frames, height, width = hidden_states.shape
p_t, p_h, p_w = TINY_ARCH["patch_size"]
head_dim = TINY_ARCH["attention_head_dim"]
hidden_size = TINY_ARCH["num_attention_heads"] * head_dim
rope_dim_list = [head_dim - 4 * (head_dim // 6), 2 * (head_dim // 6), 2 * (head_dim // 6)]
freqs_cos, freqs_sin = get_rotary_pos_embed(
(frames // p_t, height // p_h, width // p_w),
hidden_size,
TINY_ARCH["num_attention_heads"],
rope_dim_list,
dtype=torch.float64,
rope_theta=10000,
)
return (
mx.array(freqs_cos.float().numpy()).astype(mx.float32),
mx.array(freqs_sin.float().numpy()).astype(mx.float32),
)
def torch_reference_output(model, hidden_states, encoder_hidden_states, timestep) -> np.ndarray:
"""
Run the PyTorch model on the provided inputs and return its output as a NumPy array.
Parameters:
model: The PyTorch Wan transformer model.
hidden_states: The latent hidden states provided to the model.
encoder_hidden_states: The encoder hidden states provided to the model.
timestep: The diffusion timestep provided to the model.
Returns:
The model output as a CPU float32 NumPy array.
"""
with torch.no_grad(), set_forward_context(
current_timestep=0,
attn_metadata=None,
forward_batch=ForwardBatch(data_type="dummy"),
):
output = model(
hidden_states=hidden_states,
encoder_hidden_states=encoder_hidden_states,
timestep=timestep,
)
return output.detach().float().cpu().numpy()
def mlx_output(dit, hidden_states, encoder_hidden_states, timestep, freqs_cis) -> np.ndarray:
"""
Run the MLX DiT model on the provided inputs.
Parameters:
dit: The MLX DiT model to evaluate.
hidden_states: Input latent states.
encoder_hidden_states: Text encoder states.
timestep: Diffusion timestep input.
freqs_cis: Rotary-position embedding frequencies.
Returns:
np.ndarray: The model output as a float32 NumPy array.
"""
import mlx.core as mx
out = dit(
mx.array(hidden_states.numpy()),
mx.array(encoder_hidden_states.numpy()),
mx.array(timestep.float().numpy()),
freqs_cis,
)
mx.eval(out)
return np.array(out.astype(mx.float32))
@@ -0,0 +1,19 @@
# SPDX-License-Identifier: Apache-2.0
"""MPS must reject VSA before a model reaches an incompatible SDPA call."""
import pytest
import torch
from fastvideo.platforms import AttentionBackendEnum
from fastvideo.platforms.mps import MpsPlatform
def test_mps_rejects_video_sparse_attention_with_actionable_error() -> None:
with pytest.raises(NotImplementedError, match="TORCH_SDPA"):
MpsPlatform.get_attn_backend_cls(AttentionBackendEnum.VIDEO_SPARSE_ATTN, 64, torch.float16)
def test_mps_resolves_sdpa_for_supported_backend() -> None:
assert MpsPlatform.get_attn_backend_cls(AttentionBackendEnum.TORCH_SDPA, 64, torch.float16).endswith(
"SDPABackend"
)
+26
View File
@@ -0,0 +1,26 @@
MIT License
This directory vendors the inference path of rife-mlx
(https://github.com/xocialize/rife-mlx), an Apple MLX port of Practical-RIFE
(https://github.com/hzwer/Practical-RIFE).
Copyright (c) 2025 xocialize (rife-mlx, MLX port)
Copyright (c) hzwer (Practical-RIFE / RIFE, original work)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+17
View File
@@ -0,0 +1,17 @@
# SPDX-License-Identifier: MIT
"""Vendored rife-mlx (RIFE frame interpolation on Apple MLX).
Upstream: https://github.com/xocialize/rife-mlx, itself an MLX port of
Practical-RIFE (https://github.com/hzwer/Practical-RIFE, (c) hzwer). Both are
MIT; see ``LICENSE`` for the text.
Only the inference path is vendored, which is what ``--fast`` calls:
``utils.weights.build_model`` and ``pipeline_mlx.interpolate_pair``. Upstream's
video I/O and torch-to-MLX conversion helpers are omitted, so this package
needs no ``av``, ``PIL``, or torch import to load. Weights are still fetched at
runtime from the ``mlx-community`` Hugging Face org, exactly as upstream does.
"""
from fastvideo.third_party.rife_mlx.config import DEFAULT_VERSION, VERSIONS, VersionConfig
__all__ = ["DEFAULT_VERSION", "VERSIONS", "VersionConfig"]
+45
View File
@@ -0,0 +1,45 @@
"""Version registry — pinned config for the RIFE checkpoint(s) we port.
Source of truth: the bundled `IFNet_HDv3.py` + `flownet.pkl` that ship together
per version (Google Drive), NOT the GitHub repo (which omits the version arch).
CONFIRM gate #3: pin the exact arch from the downloaded package, never guess.
This round pins **RIFE 4.25** (upstream's recommended default; "anime scenes
significantly improved"). Arch PINNED in P1 from the bundled IFNet_HDv3.py +
flownet.pkl shapes: 5 IFBlocks (the 4.25 "more flow blocks" note), channels
[192,128,96,64,32], in_planes [15,28,28,28,28] (blocks 1-4 add the running
4-ch flow inside IFBlock.forward), scale_list [16,8,4,2,1] (÷ the --scale knob).
Activation is LeakyReLU(0.2). All F.interpolate use align_corners=False; warp
grid_sample uses align_corners=True. 40 teacher/caltime keys are train-only.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class VersionConfig:
name: str # internal key
hf_name: str # published HF repo name (mlx-community/<hf_name>)
gdrive_id: str # per-version package (model .py + flownet.pkl)
# arch params — PINNED from bundled v4.25 IFNet_HDv3.py + flownet.pkl (P1)
scale_list: tuple[float, ...] = (16, 8, 4, 2, 1)
block_channels: tuple[int, ...] = (192, 128, 96, 64, 32)
block_in_planes: tuple[int, ...] = (15, 28, 28, 28, 28)
pad_to: int = 64 # 5 blocks downsample; pad H,W to a multiple of 64
# warp / interpolate semantics (verified from source)
grid_align_corners: bool = True # warp grid_sample
grid_padding_mode: str = "border"
interp_align_corners: bool = False # F.interpolate in IFBlock
VERSIONS: dict[str, VersionConfig] = {
"4.25": VersionConfig(
name="4.25",
hf_name="RIFE-4.25",
gdrive_id="1ZKjcbmt1hypiFprJPIKW0Tt0lr_2i7bg",
),
}
DEFAULT_VERSION = "4.25"
+142
View File
@@ -0,0 +1,142 @@
"""IFNet (RIFE 4.25) — MLX port, isomorphic to the bundled train_log/IFNet_HDv3.py.
NHWC throughout. Translated line-by-line from the pinned v4.25 source; only
PyTorch ops -> MLX ops and NCHW -> NHWC change. Parity-locked in P3 (Gate B).
Arch (pinned): 5 IFBlocks c=[192,128,96,64,32], in_planes=[15,28,28,28,28];
conv = Conv2d + LeakyReLU(0.2); ResConv = conv*beta + x -> LeakyReLU; lastconv =
ConvTranspose2d(c,52,4,2,1) + PixelShuffle(2) -> 13ch (flow4+mask1+feat8); Head
encoder -> 4ch; F.interpolate align_corners=False; warp align_corners=True.
MLX param keys (post-conversion) match torch except: conv0.{j}.0.* -> conv0.{j}.conv.*
and lastconv.0.* -> lastconv.* (see utils/convert.py).
"""
from __future__ import annotations
import mlx.core as mx
import mlx.nn as nn
from ..ops.interpolate import interpolate_bilinear
from ..ops.spatial import pixel_shuffle_nhwc
from .warplayer import warp
_SLOPE = 0.2
def _lrelu(x):
return nn.leaky_relu(x, _SLOPE)
class Conv(nn.Module):
"""conv = Conv2d + LeakyReLU(0.2). key: <p>.conv.weight"""
def __init__(self, i, o, k=3, s=1, p=1):
super().__init__()
self.conv = nn.Conv2d(i, o, kernel_size=k, stride=s, padding=p)
def __call__(self, x):
return _lrelu(self.conv(x))
class ResConv(nn.Module):
"""conv*beta + x -> LeakyReLU. keys: .conv.weight/bias, .beta"""
def __init__(self, c):
super().__init__()
self.conv = nn.Conv2d(c, c, kernel_size=3, stride=1, padding=1)
self.beta = mx.ones((1, 1, 1, c)) # NHWC per-channel
def __call__(self, x):
return _lrelu(self.conv(x) * self.beta + x)
class Head(nn.Module):
"""Feature encoder. keys: cnn0,cnn1,cnn2 (Conv2d), cnn3 (ConvTranspose2d)."""
def __init__(self):
super().__init__()
self.cnn0 = nn.Conv2d(3, 16, 3, stride=2, padding=1)
self.cnn1 = nn.Conv2d(16, 16, 3, stride=1, padding=1)
self.cnn2 = nn.Conv2d(16, 16, 3, stride=1, padding=1)
self.cnn3 = nn.ConvTranspose2d(16, 4, 4, stride=2, padding=1)
def __call__(self, x):
x = _lrelu(self.cnn0(x))
x = _lrelu(self.cnn1(x))
x = _lrelu(self.cnn2(x))
return self.cnn3(x)
class IFBlock(nn.Module):
def __init__(self, in_planes, c=64):
super().__init__()
self.conv0 = [Conv(in_planes, c // 2, 3, 2, 1), Conv(c // 2, c, 3, 2, 1)]
self.convblock = [ResConv(c) for _ in range(8)]
self.lastconv = nn.ConvTranspose2d(c, 4 * 13, 4, stride=2, padding=1)
def __call__(self, x, flow=None, scale=1):
x = interpolate_bilinear(x, scale_factor=1.0 / scale, align_corners=False)
if flow is not None:
flow = interpolate_bilinear(flow, scale_factor=1.0 / scale,
align_corners=False) * (1.0 / scale)
x = mx.concatenate([x, flow], axis=-1)
feat = self.conv0[0](x)
feat = self.conv0[1](feat)
for blk in self.convblock:
feat = blk(feat)
tmp = pixel_shuffle_nhwc(self.lastconv(feat), 2)
tmp = interpolate_bilinear(tmp, scale_factor=scale, align_corners=False)
flow = tmp[..., :4] * scale
mask = tmp[..., 4:5]
feat = tmp[..., 5:]
return flow, mask, feat
class IFNet(nn.Module):
def __init__(self):
super().__init__()
self.block0 = IFBlock(7 + 8, c=192)
self.block1 = IFBlock(8 + 4 + 8 + 8, c=128)
self.block2 = IFBlock(8 + 4 + 8 + 8, c=96)
self.block3 = IFBlock(8 + 4 + 8 + 8, c=64)
self.block4 = IFBlock(8 + 4 + 8 + 8, c=32)
self.encode = Head()
def __call__(self, x, timestep=0.5, scale_list=(16, 8, 4, 2, 1)):
"""x: [N,H,W,6] (img0|img1 on channel axis). Returns (flow_list, mask, merged)."""
channel = x.shape[-1] // 2
img0 = x[..., :channel]
img1 = x[..., channel:]
N, H, W, _ = img0.shape
timestep = mx.full((N, H, W, 1), float(timestep))
f0 = self.encode(img0[..., :3])
f1 = self.encode(img1[..., :3])
flow_list, merged, mask_list = [], [], []
warped_img0, warped_img1 = img0, img1
flow = None
mask = None
blocks = [self.block0, self.block1, self.block2, self.block3, self.block4]
for i in range(5):
if flow is None:
flow, mask, feat = blocks[i](
mx.concatenate([img0[..., :3], img1[..., :3], f0, f1, timestep], axis=-1),
None, scale=scale_list[i])
else:
wf0 = warp(f0, flow[..., :2])
wf1 = warp(f1, flow[..., 2:4])
fd, m0, feat = blocks[i](
mx.concatenate([warped_img0[..., :3], warped_img1[..., :3],
wf0, wf1, timestep, mask, feat], axis=-1),
flow, scale=scale_list[i])
mask = m0
flow = flow + fd
mask_list.append(mask)
flow_list.append(flow)
warped_img0 = warp(img0, flow[..., :2])
warped_img1 = warp(img1, flow[..., 2:4])
merged.append((warped_img0, warped_img1))
mask = mx.sigmoid(mask)
merged_final = warped_img0 * mask + warped_img1 * (1 - mask)
return flow_list, mask, merged_final
+37
View File
@@ -0,0 +1,37 @@
"""Model wrapper — MLX port of train_log/RIFE_HDv3.py.
Wraps IFNet and exposes inference(img0, img1, timestep, scale) -> middle frame,
matching upstream: scale_list = [16,8,4,2,1] / scale; H,W padded to a multiple of
pad_to (64; the 5-block downsample factor) and cropped back after.
"""
from __future__ import annotations
import mlx.core as mx
from .IFNet_HDv3 import IFNet
class Model:
def __init__(self, scale_list=(16, 8, 4, 2, 1), pad_to: int = 64) -> None:
self.flownet = IFNet()
self.scale_list = tuple(scale_list)
self.pad_to = pad_to
def inference(self, img0: mx.array, img1: mx.array,
timestep: float = 0.5, scale: float = 1.0) -> mx.array:
"""img0,img1: [N,H,W,3] in [0,1]. Returns the frame at `timestep`."""
N, H, W, _ = img0.shape
# pad must scale with 1/scale so the coarsest-scale interpolation
# round-trips exactly (else the recovered flow size != padded size).
pad = max(self.pad_to, int(round(self.pad_to / scale)))
ph = ((H - 1) // pad + 1) * pad
pw = ((W - 1) // pad + 1) * pad
pad = [(0, 0), (0, ph - H), (0, pw - W), (0, 0)]
i0 = mx.pad(img0, pad)
i1 = mx.pad(img1, pad)
scale_list = tuple(s / scale for s in self.scale_list)
x = mx.concatenate([i0, i1], axis=-1)
_flow, _mask, merged = self.flownet(x, timestep, scale_list)
return merged[:, :H, :W, :]
+5
View File
@@ -0,0 +1,5 @@
"""IFNet / RIFE model modules — isomorphic to upstream train_log/."""
from .warplayer import warp
__all__ = ["warp"]
+37
View File
@@ -0,0 +1,37 @@
"""warp — MLX port of model/warplayer.py (backward warp via grid_sample).
Isomorphic to upstream: builds a normalized identity grid, adds the
per-pixel flow (normalized by (size-1)/2), and bilinear grid_samples the
input with border padding + align_corners=True.
NHWC throughout. flow is [N,H,W,2] with channel 0 = x (horizontal), 1 = y.
"""
from __future__ import annotations
import mlx.core as mx
from ..ops.grid_sample import grid_sample_bilinear
_grid_cache: dict[tuple, mx.array] = {}
def _identity_grid(N: int, H: int, W: int) -> mx.array:
key = (N, H, W)
if key not in _grid_cache:
xs = mx.linspace(-1.0, 1.0, W).reshape(1, 1, W, 1)
ys = mx.linspace(-1.0, 1.0, H).reshape(1, 1, H, 1) # placeholder shape
xs = mx.broadcast_to(xs, (N, H, W, 1))
ys = mx.broadcast_to(mx.linspace(-1.0, 1.0, H).reshape(1, H, 1, 1), (N, H, W, 1))
_grid_cache[key] = mx.concatenate([xs, ys], axis=-1) # [N,H,W,2]
return _grid_cache[key]
def warp(tenInput: mx.array, tenFlow: mx.array) -> mx.array:
"""tenInput: [N,H,W,C]; tenFlow: [N,H,W,2] (x,y pixel-space flow)."""
N, H, W, _ = tenFlow.shape
grid = _identity_grid(N, H, W)
fx = tenFlow[..., 0:1] / ((W - 1.0) / 2.0)
fy = tenFlow[..., 1:2] / ((H - 1.0) / 2.0)
g = grid + mx.concatenate([fx, fy], axis=-1)
return grid_sample_bilinear(tenInput, g, align_corners=True, padding_mode="border")
+6
View File
@@ -0,0 +1,6 @@
"""MLX ops hand-rolled for RIFE (no native equivalents)."""
from .grid_sample import grid_sample_bilinear
from .interpolate import interpolate_bilinear
__all__ = ["grid_sample_bilinear", "interpolate_bilinear"]
+60
View File
@@ -0,0 +1,60 @@
"""Bilinear grid_sample (NHWC) — MLX equivalent of torch.nn.functional.grid_sample.
RIFE's `warp` calls grid_sample(mode='bilinear', padding_mode='border',
align_corners=True). No native MLX op — hand-rolled here. This is the crux
surface of the port; parity-locked vs torch in P2 (Gate A).
Conventions (match torch):
- grid[..., 0] = x (width, normalized [-1,1]); grid[..., 1] = y (height).
- align_corners=True: -1 -> pixel 0, +1 -> pixel (size-1).
- padding_mode='border': neighbor integer indices are clamped to the valid
range; bilinear weights come from the UNCLAMPED continuous coordinate.
"""
from __future__ import annotations
import mlx.core as mx
def grid_sample_bilinear(inp: mx.array, grid: mx.array,
align_corners: bool = True,
padding_mode: str = "border") -> mx.array:
"""inp: [N,H,W,C]; grid: [N,gH,gW,2] in [-1,1]. Returns [N,gH,gW,C]."""
if padding_mode != "border":
raise NotImplementedError("only padding_mode='border' is needed for RIFE")
N, H, W, C = inp.shape
_, gH, gW, _ = grid.shape
gx = grid[..., 0] # [N,gH,gW]
gy = grid[..., 1]
if align_corners:
ix = (gx + 1) * 0.5 * (W - 1)
iy = (gy + 1) * 0.5 * (H - 1)
else:
ix = ((gx + 1) * W - 1) * 0.5
iy = ((gy + 1) * H - 1) * 0.5
x0 = mx.floor(ix); y0 = mx.floor(iy)
x1 = x0 + 1; y1 = y0 + 1
wx1 = ix - x0; wx0 = 1.0 - wx1
wy1 = iy - y0; wy0 = 1.0 - wy1
def clampx(a): return mx.clip(a, 0, W - 1).astype(mx.int32)
def clampy(a): return mx.clip(a, 0, H - 1).astype(mx.int32)
x0c, x1c = clampx(x0), clampx(x1)
y0c, y1c = clampy(y0), clampy(y1)
inp_flat = inp.reshape(N, H * W, C)
def gather(yc, xc): # yc,xc: [N,gH,gW] int -> [N,gH,gW,C]
idx = (yc * W + xc).reshape(N, gH * gW, 1)
idx = mx.broadcast_to(idx, (N, gH * gW, C))
g = mx.take_along_axis(inp_flat, idx, axis=1)
return g.reshape(N, gH, gW, C)
v00 = gather(y0c, x0c); v01 = gather(y0c, x1c)
v10 = gather(y1c, x0c); v11 = gather(y1c, x1c)
w00 = (wy0 * wx0)[..., None]; w01 = (wy0 * wx1)[..., None]
w10 = (wy1 * wx0)[..., None]; w11 = (wy1 * wx1)[..., None]
return v00 * w00 + v01 * w01 + v10 * w10 + v11 * w11
+51
View File
@@ -0,0 +1,51 @@
"""Bilinear resize (NHWC) — MLX equivalent of F.interpolate(mode='bilinear').
RIFE uses bilinear resize for the coarse-to-fine scale pyramid inside IFNet and
for the `--scale` knob (0.25..4.0). align_corners is pinned from the bundled
IFNet_HDv3.py in P3 (RIFE 4.x typically uses align_corners=False). Parity-locked
in P2 alongside grid_sample.
"""
from __future__ import annotations
import mlx.core as mx
def _sample_coords(out: int, in_: int, align_corners: bool) -> mx.array:
dst = mx.arange(out, dtype=mx.float32)
if align_corners:
scale = (in_ - 1) / (out - 1) if out > 1 else 0.0
return dst * scale
scale = in_ / out
return (dst + 0.5) * scale - 0.5
def _bilinear_1d(x: mx.array, axis: int, out: int, align_corners: bool) -> mx.array:
"""Resample one spatial axis with bilinear weights + border clamp."""
in_ = x.shape[axis]
if in_ == out:
return x
src = _sample_coords(out, in_, align_corners) # [out]
i0 = mx.floor(src); i1 = i0 + 1
w1 = src - i0; w0 = 1.0 - w1
i0 = mx.clip(i0, 0, in_ - 1).astype(mx.int32)
i1 = mx.clip(i1, 0, in_ - 1).astype(mx.int32)
g0 = mx.take(x, i0, axis=axis)
g1 = mx.take(x, i1, axis=axis)
shape = [1] * x.ndim; shape[axis] = out
w0 = w0.reshape(shape); w1 = w1.reshape(shape)
return g0 * w0 + g1 * w1
def interpolate_bilinear(x: mx.array, size: tuple[int, int] | None = None,
scale_factor: float | None = None,
align_corners: bool = False) -> mx.array:
"""x: [N,H,W,C]. Resize H,W to `size` or by `scale_factor` (bilinear)."""
N, H, W, C = x.shape
if size is not None:
oH, oW = size
else:
oH, oW = int(round(H * scale_factor)), int(round(W * scale_factor))
x = _bilinear_1d(x, axis=1, out=oH, align_corners=align_corners)
x = _bilinear_1d(x, axis=2, out=oW, align_corners=align_corners)
return x
+18
View File
@@ -0,0 +1,18 @@
"""NHWC pixel-shuffle — verified (C,r,r) ordering matching torch.nn.PixelShuffle.
Reused from the realesrgan-mlx port (mlx-porting pitfall #7). RIFE's IFBlock
lastconv ends in nn.PixelShuffle(2).
"""
from __future__ import annotations
import mlx.core as mx
def pixel_shuffle_nhwc(x: mx.array, r: int) -> mx.array:
"""[N,H,W,C*r^2] -> [N,H*r,W*r,C], channel split (C,r,r) (torch-matching)."""
n, h, w, c_in = x.shape
c = c_in // (r * r)
x = x.reshape((n, h, w, c, r, r))
x = mx.transpose(x, (0, 1, 4, 2, 5, 3)) # (N,H,r_i,W,r_j,C)
return x.reshape((n, h * r, w * r, c))
+80
View File
@@ -0,0 +1,80 @@
"""High-level interpolation + CLI (image-pair + full video). P5.
rife-mlx -i in.mp4 -o out.mp4 --multi 2 --scale 1.0
rife-mlx --img0 a.png --img1 b.png -t 0.5 -o mid.png
--multi N inserts N-1 frames between each pair (at t = i/N); output fps = N*input.
--scale {0.25,0.5,1,2,4} sets the coarse-to-fine pyramid scale (0.5 for 4K).
"""
from __future__ import annotations
import argparse
import mlx.core as mx
import numpy as np
from .config import DEFAULT_VERSION
from .utils.weights import build_model
def _to_nhwc(img_u8: np.ndarray) -> mx.array:
return mx.array(img_u8.astype(np.float32)[None] / 255.0)
def _to_u8(arr: mx.array) -> np.ndarray:
a = np.clip(np.array(arr)[0], 0.0, 1.0)
return (a * 255.0).round().astype(np.uint8)
def interpolate_pair(model, img0_u8, img1_u8, timestep=0.5, scale=1.0) -> np.ndarray:
out = model.inference(_to_nhwc(img0_u8), _to_nhwc(img1_u8), timestep, scale)
mx.eval(out)
return _to_u8(out)
def interpolate_sequence(model, frames, multi=2, scale=1.0):
"""frames: list[HWC uint8] -> list with multi-1 frames inserted per pair."""
out = []
for i in range(len(frames) - 1):
out.append(frames[i])
for j in range(1, multi):
out.append(interpolate_pair(model, frames[i], frames[i + 1], j / multi, scale))
out.append(frames[-1])
return out
def cli_main() -> None:
p = argparse.ArgumentParser(description="Practical-RIFE 4.25 MLX frame interpolation")
p.add_argument("-i", "--input", help="input video")
p.add_argument("--img0"); p.add_argument("--img1")
p.add_argument("-o", "--output", required=True)
p.add_argument("-t", "--timestep", type=float, default=0.5, help="image-pair mode")
p.add_argument("-m", "--multi", type=int, default=2, help="video fps multiplier")
p.add_argument("-s", "--scale", type=float, default=1.0,
choices=[0.25, 0.5, 1.0, 2.0, 4.0])
p.add_argument("-n", "--version", default=DEFAULT_VERSION)
p.add_argument("--weights_dir", default=None)
args = p.parse_args()
model = build_model(args.version, weights_dir=args.weights_dir)
if args.img0 and args.img1:
from PIL import Image
a = np.asarray(Image.open(args.img0).convert("RGB"))
b = np.asarray(Image.open(args.img1).convert("RGB"))
mid = interpolate_pair(model, a, b, args.timestep, args.scale)
Image.fromarray(mid).save(args.output)
print(f"{args.img0} + {args.img1} @t={args.timestep} -> {args.output} {mid.shape}")
return
from .video import read_frames, write_video
frames, fps = read_frames(args.input)
out_frames = interpolate_sequence(model, frames, args.multi, args.scale)
write_video(args.output, out_frames, fps * args.multi, audio_from=args.input)
print(f"{args.input} ({len(frames)}f @{fps:.2f}) -> {args.output} "
f"({len(out_frames)}f @{fps * args.multi:.2f}, audio passthrough)")
if __name__ == "__main__":
cli_main()
+1
View File
@@ -0,0 +1 @@
"""Weight loading + conversion utilities."""
+44
View File
@@ -0,0 +1,44 @@
"""Model build + weight load (HF auto-download / local override).
Resolution: weights_dir arg | $RIFE_MLX_WEIGHTS_DIR | dist/<hf_name> | HF
mlx-community/<hf_name>. Each dir: model.safetensors + config.json.
"""
from __future__ import annotations
import os
import mlx.core as mx
from ..config import VERSIONS, VersionConfig
from ..model.RIFE_HDv3 import Model
WEIGHTS_DIR_ENV = "RIFE_MLX_WEIGHTS_DIR"
HF_ORG = "mlx-community"
def resolve_repo_id(version: str) -> str:
return f"{HF_ORG}/{VERSIONS[version].hf_name}"
def _resolve_dir(cfg: VersionConfig, weights_dir: str | None) -> str:
if weights_dir:
return weights_dir
env = os.environ.get(WEIGHTS_DIR_ENV)
if env and os.path.isdir(env):
return env
local = os.path.join("dist", cfg.hf_name)
if os.path.isdir(local):
return local
from huggingface_hub import snapshot_download
return snapshot_download(repo_id=f"{HF_ORG}/{cfg.hf_name}")
def build_model(version: str = "4.25", weights_dir: str | None = None) -> Model:
cfg = VERSIONS[version]
wdir = _resolve_dir(cfg, weights_dir)
weights = dict(mx.load(os.path.join(wdir, "model.safetensors")).items())
model = Model(scale_list=cfg.scale_list, pad_to=cfg.pad_to)
model.flownet.load_weights(list(weights.items()), strict=True)
mx.eval(model.flownet.parameters())
return model
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Ollin Boer Bohan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+10
View File
@@ -0,0 +1,10 @@
# SPDX-License-Identifier: MIT
"""Vendored TAEHV (Tiny AutoEncoder for Hunyuan/Wan video latents).
Upstream: https://github.com/madebyollin/taehv -- see ``taehv.py``'s header for
provenance and ``LICENSE`` for the MIT license text.
"""
from fastvideo.third_party.taehv.taehv import TAEHV
__all__ = ["TAEHV"]
+475
View File
@@ -0,0 +1,475 @@
# Vendored from https://github.com/madebyollin/taehv (MIT License, (c) 2025
# Ollin Boer Bohan; see LICENSE in this directory).
#
# Source file: https://raw.githubusercontent.com/madebyollin/taehv/main/taehv.py
# Accessed: 2026-07-02
# Upstream file sha256: 1e228d34d47e2f95b17f1aa190f551d89ce5ef4af0f82be459a46bea81ba3fd2
#
# Vendored unmodified below this header so the Apple Silicon TAEHV decode path
# (fastvideo/mlx_runtime/taehv_decode.py) does not download and execute remote
# code at runtime. To update, re-fetch the file, refresh the sha256 above, and
# re-run fastvideo/tests/mlx/.
#!/usr/bin/env python3
"""
Tiny AutoEncoder for Hunyuan Video
(DNN for encoding / decoding videos to Hunyuan Video's latent space)
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
from tqdm.auto import tqdm
from collections import namedtuple
TWorkItem = namedtuple("TWorkItem", ("input_tensor", "block_index"))
def conv(n_in, n_out, **kwargs):
return nn.Conv2d(n_in, n_out, 3, padding=1, **kwargs)
class Clamp(nn.Module):
def forward(self, x):
return torch.tanh(x / 3) * 3
class MemBlock(nn.Module):
def __init__(self, n_in, n_out):
super().__init__()
self.conv = nn.Sequential(conv(n_in * 2, n_out), nn.ReLU(inplace=True), conv(n_out, n_out), nn.ReLU(inplace=True), conv(n_out, n_out))
self.skip = nn.Conv2d(n_in, n_out, 1, bias=False) if n_in != n_out else nn.Identity()
self.act = nn.ReLU(inplace=True)
def forward(self, x, past):
return self.act(self.conv(torch.cat([x, past], 1)) + self.skip(x))
class TPool(nn.Module):
def __init__(self, n_f, stride):
super().__init__()
self.stride = stride
self.conv = nn.Conv2d(n_f*stride,n_f, 1, bias=False)
def forward(self, x):
_NT, C, H, W = x.shape
return self.conv(x.reshape(-1, self.stride * C, H, W))
class TGrow(nn.Module):
def __init__(self, n_f, stride):
super().__init__()
self.stride = stride
self.conv = nn.Conv2d(n_f, n_f*stride, 1, bias=False)
def forward(self, x):
_NT, C, H, W = x.shape
x = self.conv(x)
return x.reshape(-1, C, H, W)
def apply_model_with_memblocks_parallel(model, x, show_progress_bar):
"""
Apply a sequential model with memblocks to the given input,
with parallelization over the time axis and iteration over blocks.
Args:
- model: nn.Sequential of blocks to apply
- x: input data, of dimensions NTCHW
- show_progress_bar: if True, enables tqdm progressbar display
Returns NTCHW tensor of output data.
"""
assert x.ndim == 5, f"TAEHV operates on NTCHW tensors, but got {x.ndim}-dim tensor"
N, T, C, H, W = x.shape
x = x.reshape(N*T, C, H, W)
# parallel over input timesteps, iterate over blocks
for b in tqdm(model, disable=not show_progress_bar):
if isinstance(b, MemBlock):
NT, C, H, W = x.shape
T = NT // N
_x = x.reshape(N, T, C, H, W)
# pad with zeros along time axis (i.e. empty memory), slice
block_memory = F.pad(_x, (0,0,0,0,0,0,1,0), value=0)[:,:T].reshape(x.shape)
x = b(x, block_memory)
else:
x = b(x)
NT, C, H, W = x.shape
T = NT // N
return x.view(N, T, C, H, W)
def apply_model_with_memblocks_sequential_single_step(model, memory, work_queue, progress_bar=None):
"""
Process the work queue (a graph traversal over blocks and timesteps)
until an output frame is produced or the queue is empty.
Mutates memory and work_queue in place.
Returns N1CHW output tensor, or None if the queue needs more input.
"""
while work_queue:
xt, i = work_queue.pop(0)
if progress_bar is not None and i == 0:
progress_bar.update(1)
if i == len(model):
return xt.unsqueeze(1)
b = model[i]
if isinstance(b, MemBlock):
# mem blocks are simple since we're visiting the graph in causal order
if memory[i] is None:
xt_new = b(xt, xt * 0)
else:
xt_new = b(xt, memory[i])
memory[i] = xt
work_queue.insert(0, TWorkItem(xt_new, i+1))
elif isinstance(b, TPool):
# pool blocks accumulate inputs until they have enough to pool
if memory[i] is None:
memory[i] = []
memory[i].append(xt)
if len(memory[i]) > b.stride:
raise ValueError(f"TPool memory overflow: {len(memory[i])} items for stride {b.stride}")
elif len(memory[i]) == b.stride:
N, C, H, W = xt.shape
xt = b(torch.cat(memory[i], 1).view(N*b.stride, C, H, W))
memory[i] = []
work_queue.insert(0, TWorkItem(xt, i+1))
elif isinstance(b, TGrow):
xt = b(xt)
NT, C, H, W = xt.shape
for xt_next in reversed(xt.view(NT//b.stride, b.stride*C, H, W).chunk(b.stride, 1)):
work_queue.insert(0, TWorkItem(xt_next, i+1))
else:
xt = b(xt)
work_queue.insert(0, TWorkItem(xt, i+1))
return None
def apply_model_with_memblocks_sequential(model, x, show_progress_bar):
"""
Apply a sequential model with memblocks to the given input,
with iteration over timesteps as well as blocks.
Args:
- model: nn.Sequential of blocks to apply
- x: input data, of dimensions NTCHW
- show_progress_bar: if True, enables tqdm progressbar display
Returns NTCHW tensor of output data.
"""
assert x.ndim == 5, f"TAEHV operates on NTCHW tensors, but got {x.ndim}-dim tensor"
work_queue = [TWorkItem(xt, 0) for xt in x.unbind(1)]
memory = [None] * len(model)
progress_bar = tqdm(range(len(work_queue)), disable=not show_progress_bar)
out = []
while work_queue:
xt = apply_model_with_memblocks_sequential_single_step(model, memory, work_queue, progress_bar)
if xt is not None:
out.append(xt)
progress_bar.close()
return torch.cat(out, 1)
def apply_model_with_memblocks(model, x, parallel, show_progress_bar):
"""
Apply a sequential model with memblocks to the given input.
Args:
- model: nn.Sequential of blocks to apply
- x: input data, of dimensions NTCHW
- parallel: if True, parallelize over timesteps (fast but uses O(T) memory)
if False, each timestep will be processed sequentially (slow but uses O(1) memory)
- show_progress_bar: if True, enables tqdm progressbar display
Returns NTCHW tensor of output data.
"""
if parallel:
return apply_model_with_memblocks_parallel(model, x, show_progress_bar)
else:
return apply_model_with_memblocks_sequential(model, x, show_progress_bar)
class TAEHV(nn.Module):
def __init__(self, checkpoint_path="taehv.pth", encoder_time_downscale=(True, True, False), decoder_time_upscale=(False, True, True), decoder_space_upscale=(True, True, True), patch_size=1, latent_channels=16):
"""Initialize pretrained TAEHV from the given checkpoint.
Arg:
checkpoint_path: path to weight file to load. taehv.pth for Hunyuan, taew2_1.pth for Wan 2.1.
encoder_time_downscale: whether temporal downsampling is enabled for each block.
decoder_time_upscale: whether temporal upsampling is enabled for each block. upsampling can be disabled for a cheaper preview.
decoder_space_upscale: whether spatial upsampling is enabled for each block. upsampling can be disabled for a cheaper preview.
patch_size: input/output pixelshuffle patch-size for this model.
latent_channels: number of latent channels (z dim) for this model.
"""
super().__init__()
self.patch_size = patch_size
self.latent_channels = latent_channels
self.image_channels = 3
if len(decoder_time_upscale) == 2:
decoder_time_upscale = (False, *decoder_time_upscale)
self.is_cogvideox = checkpoint_path is not None and "taecvx" in checkpoint_path
if checkpoint_path is not None and "taew2_2" in checkpoint_path:
self.patch_size, self.latent_channels = 2, 48
if checkpoint_path is not None and "taehv1_5" in checkpoint_path:
self.patch_size, self.latent_channels = 2, 32
if checkpoint_path is not None and "taeltx" in checkpoint_path: # same for both 2 and 2.3
self.patch_size, self.latent_channels, encoder_time_downscale, decoder_time_upscale = 4, 128, (True, True, True), (True, True, True)
self.encoder = nn.Sequential(
conv(self.image_channels*self.patch_size**2, 64), nn.ReLU(inplace=True),
TPool(64, 2 if encoder_time_downscale[0] else 1), conv(64, 64, stride=2, bias=False), MemBlock(64, 64), MemBlock(64, 64), MemBlock(64, 64),
TPool(64, 2 if encoder_time_downscale[1] else 1), conv(64, 64, stride=2, bias=False), MemBlock(64, 64), MemBlock(64, 64), MemBlock(64, 64),
TPool(64, 2 if encoder_time_downscale[2] else 1), conv(64, 64, stride=2, bias=False), MemBlock(64, 64), MemBlock(64, 64), MemBlock(64, 64),
conv(64, self.latent_channels),
)
n_f = [256, 128, 64, 64]
self.decoder = nn.Sequential(
Clamp(), conv(self.latent_channels, n_f[0]), nn.ReLU(inplace=True),
MemBlock(n_f[0], n_f[0]), MemBlock(n_f[0], n_f[0]), MemBlock(n_f[0], n_f[0]), nn.Upsample(scale_factor=2 if decoder_space_upscale[0] else 1), TGrow(n_f[0], 2 if decoder_time_upscale[0] else 1), conv(n_f[0], n_f[1], bias=False),
MemBlock(n_f[1], n_f[1]), MemBlock(n_f[1], n_f[1]), MemBlock(n_f[1], n_f[1]), nn.Upsample(scale_factor=2 if decoder_space_upscale[1] else 1), TGrow(n_f[1], 2 if decoder_time_upscale[1] else 1), conv(n_f[1], n_f[2], bias=False),
MemBlock(n_f[2], n_f[2]), MemBlock(n_f[2], n_f[2]), MemBlock(n_f[2], n_f[2]), nn.Upsample(scale_factor=2 if decoder_space_upscale[2] else 1), TGrow(n_f[2], 2 if decoder_time_upscale[2] else 1), conv(n_f[2], n_f[3], bias=False),
nn.ReLU(inplace=True), conv(n_f[3], self.image_channels*self.patch_size**2),
)
# computed properties
self.t_downscale = 2**sum(t.stride == 2 for t in self.encoder if isinstance(t, TPool))
self.t_upscale = 2**sum(t.stride == 2 for t in self.decoder if isinstance(t, TGrow))
self.frames_to_trim = self.t_upscale - 1
if checkpoint_path is not None:
self.load_state_dict(self.patch_tgrow_layers(torch.load(checkpoint_path, map_location="cpu", weights_only=True)))
def patch_tgrow_layers(self, sd):
"""Patch TGrow layers to use a smaller kernel if needed.
Args:
sd: state dict to patch
"""
new_sd = self.state_dict()
for i, layer in enumerate(self.decoder):
if isinstance(layer, TGrow):
key = f"decoder.{i}.conv.weight"
if sd[key].shape[0] > new_sd[key].shape[0]:
# take the last-timestep output channels
sd[key] = sd[key][-new_sd[key].shape[0]:]
return sd
def preprocess_input_frames(self, x):
"""Preprocess RGB input frames prior to the main encoder sequence."""
if self.patch_size > 1: x = F.pixel_unshuffle(x, self.patch_size)
return x
def encode_video(self, x, parallel=True, show_progress_bar=True):
"""Encode a sequence of frames.
Args:
x: input NTCHW RGB (C=3) tensor with values in [0, 1].
parallel: if True, all frames will be processed at once.
(this is faster but may require more memory).
if False, frames will be processed sequentially.
Returns NTCHW latent tensor with ~Gaussian values.
"""
x = self.preprocess_input_frames(x)
if x.shape[1] % self.t_downscale != 0:
# pad at end to multiple of self.t_downscale
n_pad = self.t_downscale - x.shape[1] % self.t_downscale
padding = x[:, -1:].repeat_interleave(n_pad, dim=1)
x = torch.cat([x, padding], 1)
return apply_model_with_memblocks(self.encoder, x, parallel, show_progress_bar)
def postprocess_output_frames(self, x):
"""Postprocess RGB frames after the main decoder sequence."""
if self.patch_size > 1: x = F.pixel_shuffle(x, self.patch_size)
return x.clamp_(0, 1)
def decode_video(self, x, parallel=True, show_progress_bar=True):
"""Decode a sequence of frames.
Args:
x: input NTCHW latent (C=self.latent_channels) tensor with ~Gaussian values.
parallel: if True, all frames will be processed at once.
(this is faster but may require more memory).
if False, frames will be processed sequentially.
Returns NTCHW RGB tensor with ~[0, 1] values.
"""
skip_trim = self.is_cogvideox and x.shape[1] % 2 == 0
x = apply_model_with_memblocks(self.decoder, x, parallel, show_progress_bar)
x = self.postprocess_output_frames(x)
if skip_trim:
# skip trimming for cogvideox to make frame counts match.
# this still doesn't have correct temporal alignment for certain frame counts
# (cogvideox seems to pad at the start?), but for multiple-of-4 it's fine.
return x
return x[:, self.frames_to_trim:]
class StreamingTAEHV(nn.Module):
def __init__(self, taehv):
"""Streaming wrapper around TAEHV for real-time use-cases (where not all inputs are available immediately).
Encode-decode (video-to-video) usage:
streaming = StreamingTAEHV(taehv)
for frame in video_frames:
latent = streaming.encode(frame_tensor)
decoded = streaming.decode(latent) # feeds latent if not None, then returns next frame
if decoded is not None:
display(decoded)
for frame in streaming.flush():
display(frame)
Decode-only (world model) usage:
streaming = StreamingTAEHV(taehv)
while running:
latent = world_model.step() # latent represents t_upscale frames
frame = streaming.decode(latent) # returns first frame immediately
while frame is not None: # retrieve remaining frames from this latent
display(frame)
frame = streaming.decode()
"""
super().__init__()
self.taehv = taehv
self.reset()
def reset(self):
"""Reset all internal state. Call this to start encoding/decoding a new stream."""
self.encoder_work_queue, self.encoder_memory = [], [None] * len(self.taehv.encoder)
self.decoder_work_queue, self.decoder_memory = [], [None] * len(self.taehv.decoder)
self.n_frames_encoded, self.n_frames_decoded = 0, 0
self._last_encoder_input_frame = None
def encode(self, x=None):
"""Feed an input frame (optional) and try to produce an encoder output.
The encoder accumulates t_downscale input frames before producing one latent,
so most calls will return None. Use flush_encoder() at end-of-stream to pad and
drain any remaining latents.
Args:
x: NTCHW RGB frame tensor with values in [0, 1], or None to just process pending work.
Returns: N1CHW latent tensor, or None if not enough input has been accumulated.
"""
if x is not None:
assert x.ndim == 5 and x.shape[2] == self.taehv.image_channels, f"Expected NTCHW frames but got {x.shape=}"
self._last_encoder_input_frame = x[:, -1:]
x = self.taehv.preprocess_input_frames(x)
self.encoder_work_queue.extend(TWorkItem(xt, 0) for xt in x.unbind(1))
self.n_frames_encoded += x.shape[1]
xt = apply_model_with_memblocks_sequential_single_step(
self.taehv.encoder, self.encoder_memory, self.encoder_work_queue)
return xt
def decode(self, x=None):
"""Feed a latent (optional) and try to produce a decoded frame.
Each latent produces t_upscale output frames due to temporal upscaling. The first
decode(latent) call returns the first of these frames; call decode() with no argument
to retrieve the rest, one at a time. Each call does the minimum decoder work needed to
produce one frame.
Startup frames (the first frames_to_trim raw decoder outputs, used for causal alignment
with the reference VAE) are consumed internally and never returned.
Args:
x: NTCHW latent tensor, or None to retrieve the next pending frame.
Returns: N1CHW decoded RGB frame tensor, or None if the queue needs more input.
"""
if x is not None:
assert x.ndim == 5 and x.shape[2] == self.taehv.latent_channels, f"Expected NTCHW latents but got {x.shape=}"
self.decoder_work_queue.extend(TWorkItem(xt, 0) for xt in x.unbind(1))
while True:
xt = apply_model_with_memblocks_sequential_single_step(
self.taehv.decoder, self.decoder_memory, self.decoder_work_queue)
if xt is None:
return None
self.n_frames_decoded += 1
# skip startup frames (to match decode_video trim behavior)
if not self.taehv.is_cogvideox and self.n_frames_decoded <= self.taehv.frames_to_trim:
continue
return self.taehv.postprocess_output_frames(xt)
def flush_encoder(self):
"""Pad (if needed) and drain all remaining latents from the encoder.
Returns list of N1CHW latent tensors.
"""
latents = []
if self._last_encoder_input_frame is not None and self.n_frames_encoded % self.taehv.t_downscale != 0:
n_pad = self.taehv.t_downscale - self.n_frames_encoded % self.taehv.t_downscale
for _ in range(n_pad):
lat = self.encode(self._last_encoder_input_frame)
if lat is not None:
latents.append(lat)
while (lat := self.encode()) is not None:
latents.append(lat)
return latents
def flush_decoder(self):
"""Drain all remaining decoded frames from the decoder.
Returns list of N1CHW decoded RGB frame tensors.
"""
frames = []
while (frame := self.decode()) is not None:
frames.append(frame)
return frames
def flush(self):
"""Flush encoder (with padding) and decoder, returning all remaining decoded frames.
Returns list of N1CHW decoded RGB frame tensors.
"""
frames = []
for latent in self.flush_encoder():
frame = self.decode(latent)
if frame is not None:
frames.append(frame)
frames.extend(self.flush_decoder())
return frames
@torch.no_grad()
def main():
"""Run TAEHV roundtrip reconstruction on the given video paths."""
import os
import sys
import cv2 # no highly esteemed deed is commemorated here
class VideoTensorReader:
def __init__(self, video_file_path):
self.cap = cv2.VideoCapture(video_file_path)
assert self.cap.isOpened(), f"Could not load {video_file_path}"
self.fps = self.cap.get(cv2.CAP_PROP_FPS)
def __iter__(self):
return self
def __next__(self):
ret, frame = self.cap.read()
if not ret:
self.cap.release()
raise StopIteration # End of video or error
return torch.from_numpy(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)).permute(2, 0, 1) # BGR HWC -> RGB CHW
class VideoTensorWriter:
def __init__(self, video_file_path, width_height, fps=30):
self.writer = cv2.VideoWriter(video_file_path, cv2.VideoWriter_fourcc(*'mp4v'), fps, width_height)
assert self.writer.isOpened(), f"Could not create writer for {video_file_path}"
def write(self, frame_tensor):
assert frame_tensor.ndim == 3 and frame_tensor.shape[0] == 3, f"{frame_tensor.shape}??"
self.writer.write(cv2.cvtColor(frame_tensor.permute(1, 2, 0).numpy(), cv2.COLOR_RGB2BGR)) # RGB CHW -> BGR HWC
def __del__(self):
if hasattr(self, 'writer'): self.writer.release()
dev = torch.device("cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu")
dtype = torch.float16
checkpoint_path = os.getenv("TAEHV_CHECKPOINT_PATH", "taehv.pth")
checkpoint_name = os.path.splitext(os.path.basename(checkpoint_path))[0]
print(f"Using device \033[31m{dev}\033[0m, dtype \033[32m{dtype}\033[0m, checkpoint \033[34m{checkpoint_name}\033[0m ({checkpoint_path})")
taehv = TAEHV(checkpoint_path=checkpoint_path).to(dev, dtype)
for video_path in sys.argv[1:]:
print(f"Processing {video_path}...")
video_in = VideoTensorReader(video_path)
video = torch.stack(list(video_in), 0)[None]
vid_dev = video.to(dev, dtype).div_(255.0)
# convert to device tensor
if video.numel() < 100_000_000:
print(f" {video_path} seems small enough, will process all frames in parallel")
# convert to device tensor
vid_enc = taehv.encode_video(vid_dev)
print(f" Encoded {video_path} -> {vid_enc.shape}. Decoding...")
vid_dec = taehv.decode_video(vid_enc)
print(f" Decoded {video_path} -> {vid_dec.shape}")
else:
print(f" {video_path} seems large, will process each frame sequentially")
# convert to device tensor
vid_enc = taehv.encode_video(vid_dev, parallel=False)
print(f" Encoded {video_path} -> {vid_enc.shape}. Decoding...")
vid_dec = taehv.decode_video(vid_enc, parallel=False)
print(f" Decoded {video_path} -> {vid_dec.shape}")
video_out_path = video_path + f".reconstructed_by_{checkpoint_name}.mp4"
video_out = VideoTensorWriter(video_out_path, (vid_dec.shape[-1], vid_dec.shape[-2]), fps=int(round(video_in.fps)))
for frame in vid_dec.clamp_(0, 1).mul_(255).round_().byte().cpu()[0]:
video_out.write(frame)
print(f" Saved to {video_out_path}")
if __name__ == "__main__":
main()
+1
View File
@@ -151,6 +151,7 @@ nav:
- Home: index.md
- Getting Started:
- Installation: getting_started/installation.md
- Apple Silicon FastWan: getting_started/installation/mps.md
- Quick Start: getting_started/quick_start.md
- V1 API: getting_started/v1_api.md
- Inference:
+13 -3
View File
@@ -122,9 +122,11 @@ fastvideo-kernel = [
# uv reads UV_TORCH_BACKEND / --torch-backend (not a pyproject setting). The
# supported backends (cu126, cu130) both provide torch 2.12.0.
imagebind = { git = "https://github.com/facebookresearch/ImageBind.git", rev = "53680b02d7e37b19b124fa37bae4b6c98c38f5be" }
# FA4 cute, pinned to a cutlass-4.5-compatible revision. torch.compile support
# comes from FastVideo's own custom_op wrappers.
flash-attn-4 = { git = "https://github.com/Dao-AILab/flash-attention.git", rev = "82d6441eec5d4dfec120153db2c0145ae855a083", subdirectory = "flash_attn/cute" }
# FA4 cute. This revision pins nvidia-cutlass-dsl==4.6.0.dev0; holding cutlass-dsl
# back at 4.5.x breaks the CuTe kernels at JIT time, not at import
# (see fastvideo-kernel/README.md).
# torch.compile support comes from FastVideo's own custom_op wrappers.
flash-attn-4 = { git = "https://github.com/Dao-AILab/flash-attention.git", rev = "14c377950125c70b7a9dabf9c561fca53715ac7d", subdirectory = "flash_attn/cute" }
[project.optional-dependencies]
@@ -201,6 +203,12 @@ rocm = [
"amdsmi",
]
# Apple Silicon FastWan release runtime. The range is intentionally pinned to
# the MLX version used for the checked-in parity and checkpoint tests.
mlx = [
"mlx>=0.31.2,<0.33; platform_system == 'Darwin' and platform_machine == 'arm64'",
]
[project.scripts]
fastvideo = "fastvideo.entrypoints.cli.main:main"
dreamverse-server = "dreamverse.server_entry:cli"
@@ -212,6 +220,8 @@ exclude = ["assets*", "docker*", "docs", "scripts*", "apps*", "dreamverse.tests*
[tool.setuptools.package-data]
dreamverse = ["prompts/*.md"]
"fastvideo.third_party.rife_mlx" = ["LICENSE"]
"fastvideo.third_party.taehv" = ["LICENSE"]
[tool.wheel]
exclude = ["assets*", "docker*", "docs", "scripts*", "apps*"]