Files

981 lines
37 KiB
Python

# 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