Skip to content

Commit 8f9d76a

Browse files
authored
[perf]: dispatch wide-M affine H3 MLX linears through dequant plus dense GEMM (#1788)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
1 parent a4d9a75 commit 8f9d76a

5 files changed

Lines changed: 255 additions & 12 deletions

File tree

.github/workflows/ci-macos-mlx.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ jobs:
8181
fastvideo/tests/mlx/test_mlx_compile_parity.py \
8282
fastvideo/tests/mlx/test_mlx_checkpoint.py \
8383
fastvideo/tests/mlx/test_mlx_checkpoint_compat.py \
84+
fastvideo/tests/mlx/test_mlx_affine_dq_gemm.py \
8485
fastvideo/tests/mlx/test_mlx_minimax_h3_parity.py \
8586
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa.py \
8687
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa_regressions.py \
@@ -143,6 +144,7 @@ jobs:
143144
fastvideo/tests/mlx/test_mlx_compile_parity.py \
144145
fastvideo/tests/mlx/test_mlx_checkpoint.py \
145146
fastvideo/tests/mlx/test_mlx_checkpoint_compat.py \
147+
fastvideo/tests/mlx/test_mlx_affine_dq_gemm.py \
146148
fastvideo/tests/mlx/test_mlx_minimax_h3_parity.py \
147149
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa.py \
148150
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa_regressions.py \

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ fastvideo/tests/ssim/reference_videos/**
133133
!fastvideo/tests/ssim/reference_videos/**/*.mp4
134134
!fastvideo/tests/ssim/reference_videos/**/*.png
135135

136+
# Local H3 MLX kernel / exactness benches (JSON, logs, frames, videos)
137+
.kernel_bench/
138+
136139
# Editor logs and local Python version pins (accidentally committed)
137140
*.nvimlog
138141
.nvimlog

fastvideo/mlx_runtime/fastwan.py

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -303,22 +303,89 @@ def quantize_matrix(weight, spec: MLXQuantizationSpec | None):
303303
)
304304

305305

306-
def linear(x, weight, bias=None):
306+
# Affine quantized_matmul is slower than dequantize + steel GEMM at H3's packed
307+
# token width. Measured on Apple M4 Max / MLX 0.32.2, INT6 group 64, BF16 acts,
308+
# Q 5376→7168: M=256 qmm is faster; M=512 dequant+GEMM is +6.4%; M≥1024 ~10%.
309+
# 832×480×124 packed M is ~14862–14994. H3 opts into this path explicitly;
310+
# shared FastWan and Wan 2.2 linears stay on quantized_matmul. Do not cache
311+
# dequantized weights. Override: FASTVIDEO_MLX_DQ_GEMM=0 off, =1 measured
312+
# floor, =<int> explicit floor.
313+
_AFFINE_DQ_GEMM_BITS = frozenset({2, 3, 4, 5, 6, 8})
314+
MLX_AFFINE_DQ_GEMM_DEFAULT_MIN_M = 768
315+
_dq_gemm_engaged = 0
316+
_dq_gemm_logged = False
317+
318+
319+
def reset_dq_gemm_telemetry() -> None:
320+
global _dq_gemm_engaged
321+
_dq_gemm_engaged = 0
322+
323+
324+
def dq_gemm_engaged() -> int:
325+
return _dq_gemm_engaged
326+
327+
328+
def affine_dq_gemm_min_m() -> int | None:
329+
raw = os.environ.get("FASTVIDEO_MLX_DQ_GEMM", "1").strip().lower()
330+
if raw in {"", "0", "off", "false", "no"}:
331+
return None
332+
if raw in {"1", "on", "true", "yes"}:
333+
return MLX_AFFINE_DQ_GEMM_DEFAULT_MIN_M
334+
try:
335+
value = int(raw)
336+
except ValueError:
337+
return MLX_AFFINE_DQ_GEMM_DEFAULT_MIN_M
338+
if value <= 0:
339+
return None
340+
return value
341+
342+
343+
def _matmul_leading_rows(x) -> int:
344+
last = int(x.shape[-1]) if x.ndim else 0
345+
if last <= 0:
346+
return 0
347+
return int(x.size) // last
348+
349+
350+
def _quantized_linear(x, weight: QuantizedMatrix, *, use_affine_dq_gemm: bool = False):
307351
import mlx.core as mx
308352

309-
if isinstance(weight, QuantizedMatrix):
310-
y = mx.quantized_matmul(
311-
x,
353+
global _dq_gemm_engaged, _dq_gemm_logged
354+
spec = weight.spec
355+
min_m = affine_dq_gemm_min_m() if use_affine_dq_gemm else None
356+
rows = _matmul_leading_rows(x)
357+
if (min_m is not None and spec.mode == "affine" and spec.bits in _AFFINE_DQ_GEMM_BITS
358+
and spec.group_size is not None and rows >= min_m):
359+
dequantized = mx.dequantize(
312360
weight.weight,
313361
weight.scales,
314362
weight.biases,
315-
transpose=True,
316-
group_size=weight.spec.group_size,
317-
bits=weight.spec.bits,
318-
mode=weight.spec.mode,
319-
).astype(x.dtype)
320-
else:
321-
y = x @ weight.T
363+
group_size=spec.group_size,
364+
bits=spec.bits,
365+
mode=spec.mode,
366+
dtype=x.dtype,
367+
)
368+
y = (x @ dequantized.T).astype(x.dtype)
369+
_dq_gemm_engaged += 1
370+
if not _dq_gemm_logged:
371+
_dq_gemm_logged = True
372+
logger.info("affine dequant+GEMM engaged (rows=%d, floor=%d, bits=%s)", rows, min_m, spec.bits)
373+
return y
374+
return mx.quantized_matmul(
375+
x,
376+
weight.weight,
377+
weight.scales,
378+
weight.biases,
379+
transpose=True,
380+
group_size=spec.group_size,
381+
bits=spec.bits,
382+
mode=spec.mode,
383+
).astype(x.dtype)
384+
385+
386+
def linear(x, weight, bias=None, *, use_affine_dq_gemm: bool = False):
387+
y = (_quantized_linear(x, weight, use_affine_dq_gemm=use_affine_dq_gemm)
388+
if isinstance(weight, QuantizedMatrix) else x @ weight.T)
322389
if bias is not None:
323390
y = y + bias
324391
return y

fastvideo/mlx_runtime/minimax_h3.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
MLXQuantizationSpec,
6262
QuantizedMatrix,
6363
ensure_quantization_supported,
64-
linear,
64+
linear as _shared_linear,
6565
quantize_matrix,
6666
silu,
6767
timestep_embedding,
@@ -81,6 +81,12 @@
8181

8282
logger = init_logger(__name__)
8383

84+
85+
def linear(x, weight, bias=None):
86+
"""Run an H3 linear with its measured wide-row affine dispatch enabled."""
87+
return _shared_linear(x, weight, bias, use_affine_dq_gemm=True)
88+
89+
8490
# ---------------------------------------------------------------------------
8591
# Constants (mirrors fastvideo/pipelines/basic/minimax_h3/packing.py)
8692
# ---------------------------------------------------------------------------
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""Parity and dispatch contracts for affine dequant + dense GEMM."""
3+
4+
from __future__ import annotations
5+
6+
import os
7+
8+
import numpy as np
9+
import pytest
10+
11+
mx = pytest.importorskip("mlx.core", reason="MLX is required for affine dq-GEMM tests")
12+
13+
from fastvideo.mlx_runtime.fastwan import ( # noqa: E402
14+
MLXQuantizationSpec,
15+
affine_dq_gemm_min_m,
16+
dq_gemm_engaged,
17+
linear,
18+
quantize_matrix,
19+
reset_dq_gemm_telemetry,
20+
)
21+
from fastvideo.mlx_runtime.minimax_h3 import linear as h3_linear # noqa: E402
22+
23+
AFFINE_BITS = (2, 3, 4, 5, 6, 8)
24+
GROUP_SIZES = (32, 64, 128)
25+
26+
27+
def _qmm(x, weight):
28+
return mx.quantized_matmul(
29+
x,
30+
weight.weight,
31+
weight.scales,
32+
weight.biases,
33+
transpose=True,
34+
group_size=weight.spec.group_size,
35+
bits=weight.spec.bits,
36+
mode=weight.spec.mode,
37+
).astype(x.dtype)
38+
39+
40+
def _try_quantize(out_features: int, in_features: int, bits: int, group_size: int):
41+
spec = MLXQuantizationSpec(mode="affine", bits=bits, group_size=group_size)
42+
weight = mx.random.normal((out_features, in_features)).astype(mx.bfloat16)
43+
try:
44+
quantized = quantize_matrix(weight, spec)
45+
mx.eval(quantized.weight, quantized.scales, quantized.biases)
46+
return quantized
47+
except Exception as exc: # noqa: BLE001 - MLX support varies by version.
48+
pytest.skip(f"affine bits={bits} group_size={group_size} unsupported: {exc}")
49+
50+
51+
def _rel_l2(a, b) -> float:
52+
left = np.asarray(a.astype(mx.float32))
53+
right = np.asarray(b.astype(mx.float32))
54+
denom = max(float(np.linalg.norm(left)), 1e-12)
55+
return float(np.linalg.norm(left - right) / denom)
56+
57+
58+
@pytest.mark.parametrize("bits", AFFINE_BITS)
59+
@pytest.mark.parametrize("group_size", GROUP_SIZES)
60+
def test_dq_gemm_matches_qmm_for_supported_bit_widths(bits: int, group_size: int, monkeypatch: pytest.MonkeyPatch) -> None:
61+
monkeypatch.setenv("FASTVIDEO_MLX_DQ_GEMM", "8")
62+
reset_dq_gemm_telemetry()
63+
in_features = group_size * 4
64+
out_features = group_size * 2
65+
quantized = _try_quantize(out_features, in_features, bits, group_size)
66+
x = mx.random.normal((16, in_features)).astype(mx.bfloat16)
67+
mx.eval(x)
68+
before = dq_gemm_engaged()
69+
got = h3_linear(x, quantized)
70+
ref = _qmm(x, quantized)
71+
mx.eval(got, ref)
72+
assert dq_gemm_engaged() == before + 1
73+
rel = _rel_l2(got, ref)
74+
ref_np = np.asarray(ref.astype(mx.float32))
75+
got_np = np.asarray(got.astype(mx.float32))
76+
scale = max(float(np.max(np.abs(ref_np))), 1e-3)
77+
assert rel < 2e-2, rel
78+
assert float(np.max(np.abs(got_np - ref_np))) / scale < 0.08
79+
80+
81+
def test_dq_gemm_with_bias_and_batched_rows(monkeypatch: pytest.MonkeyPatch) -> None:
82+
monkeypatch.setenv("FASTVIDEO_MLX_DQ_GEMM", "4")
83+
reset_dq_gemm_telemetry()
84+
quantized = _try_quantize(64, 128, bits=6, group_size=64)
85+
x = mx.random.normal((2, 8, 128)).astype(mx.bfloat16)
86+
bias = mx.random.normal((64, )).astype(mx.bfloat16)
87+
mx.eval(x, bias)
88+
got = h3_linear(x, quantized, bias)
89+
ref = _qmm(x, quantized) + bias
90+
mx.eval(got, ref)
91+
assert dq_gemm_engaged() == 1
92+
assert _rel_l2(got, ref) < 2e-2
93+
# transpose=True contract: output last dim is out_features.
94+
assert got.shape == (2, 8, 64)
95+
96+
97+
def test_dq_gemm_stays_on_qmm_below_threshold(monkeypatch: pytest.MonkeyPatch) -> None:
98+
monkeypatch.setenv("FASTVIDEO_MLX_DQ_GEMM", "768")
99+
reset_dq_gemm_telemetry()
100+
quantized = _try_quantize(64, 128, bits=6, group_size=64)
101+
x = mx.random.normal((32, 128)).astype(mx.bfloat16)
102+
mx.eval(x)
103+
got = h3_linear(x, quantized)
104+
ref = _qmm(x, quantized)
105+
mx.eval(got, ref)
106+
assert dq_gemm_engaged() == 0
107+
np.testing.assert_array_equal(np.asarray(got.astype(mx.float32)), np.asarray(ref.astype(mx.float32)))
108+
109+
110+
def test_dq_gemm_env_zero_disables_dispatch(monkeypatch: pytest.MonkeyPatch) -> None:
111+
monkeypatch.setenv("FASTVIDEO_MLX_DQ_GEMM", "0")
112+
assert affine_dq_gemm_min_m() is None
113+
reset_dq_gemm_telemetry()
114+
quantized = _try_quantize(64, 128, bits=6, group_size=64)
115+
x = mx.random.normal((1024, 128)).astype(mx.bfloat16)
116+
mx.eval(x)
117+
got = h3_linear(x, quantized)
118+
ref = _qmm(x, quantized)
119+
mx.eval(got, ref)
120+
assert dq_gemm_engaged() == 0
121+
np.testing.assert_array_equal(np.asarray(got.astype(mx.float32)), np.asarray(ref.astype(mx.float32)))
122+
123+
124+
def test_shared_linear_stays_on_qmm_at_wide_m(monkeypatch: pytest.MonkeyPatch) -> None:
125+
monkeypatch.setenv("FASTVIDEO_MLX_DQ_GEMM", "1")
126+
reset_dq_gemm_telemetry()
127+
quantized = _try_quantize(64, 128, bits=6, group_size=64)
128+
x = mx.random.normal((1024, 128)).astype(mx.bfloat16)
129+
got = linear(x, quantized)
130+
ref = _qmm(x, quantized)
131+
mx.eval(got, ref)
132+
assert dq_gemm_engaged() == 0
133+
np.testing.assert_array_equal(np.asarray(got.astype(mx.float32)), np.asarray(ref.astype(mx.float32)))
134+
135+
136+
def test_non_affine_weights_stay_on_quantized_matmul(monkeypatch: pytest.MonkeyPatch) -> None:
137+
monkeypatch.setenv("FASTVIDEO_MLX_DQ_GEMM", "1")
138+
reset_dq_gemm_telemetry()
139+
spec = MLXQuantizationSpec(mode="mxfp8")
140+
weight = mx.random.normal((64, 64)).astype(mx.bfloat16)
141+
try:
142+
quantized = quantize_matrix(weight, spec)
143+
mx.eval(quantized.weight, quantized.scales)
144+
except Exception as exc: # noqa: BLE001
145+
pytest.skip(f"mxfp8 unsupported: {exc}")
146+
x = mx.random.normal((1024, 64)).astype(mx.bfloat16)
147+
mx.eval(x)
148+
got = h3_linear(x, quantized)
149+
mx.eval(got)
150+
assert dq_gemm_engaged() == 0
151+
assert got.shape == (1024, 64)
152+
153+
154+
def test_default_floor_is_measured_768(monkeypatch: pytest.MonkeyPatch) -> None:
155+
monkeypatch.setenv("FASTVIDEO_MLX_DQ_GEMM", "1")
156+
assert affine_dq_gemm_min_m() == 768
157+
monkeypatch.setenv("FASTVIDEO_MLX_DQ_GEMM", "2048")
158+
assert affine_dq_gemm_min_m() == 2048
159+
monkeypatch.delenv("FASTVIDEO_MLX_DQ_GEMM", raising=False)
160+
os.environ.pop("FASTVIDEO_MLX_DQ_GEMM", None)
161+
# Default with unset env is on at the measured floor.
162+
monkeypatch.delenv("FASTVIDEO_MLX_DQ_GEMM", raising=False)
163+
if "FASTVIDEO_MLX_DQ_GEMM" in os.environ:
164+
pytest.skip("parent environment pinned FASTVIDEO_MLX_DQ_GEMM")
165+
assert affine_dq_gemm_min_m() == 768

0 commit comments

Comments
 (0)