Skip to content

Commit 77ef310

Browse files
committed
[feat]: dispatch wide-M affine INT6 through dequant plus dense GEMM
MLX quantized_matmul is slower than dequantize plus steel GEMM at H3's packed token width. Keep stored INT6 weights and use a measured M=768 floor without caching dequantized matrices.
1 parent 1559dfd commit 77ef310

3 files changed

Lines changed: 228 additions & 11 deletions

File tree

.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: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -303,22 +303,86 @@ 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. Do not cache dequantized weights.
310+
# Override: FASTVIDEO_MLX_DQ_GEMM=0 off, =1 measured floor, =<int> explicit floor.
311+
_AFFINE_DQ_GEMM_BITS = frozenset({2, 3, 4, 5, 6, 8})
312+
MLX_AFFINE_DQ_GEMM_DEFAULT_MIN_M = 768
313+
_dq_gemm_engaged = 0
314+
_dq_gemm_logged = False
315+
316+
317+
def reset_dq_gemm_telemetry() -> None:
318+
global _dq_gemm_engaged
319+
_dq_gemm_engaged = 0
320+
321+
322+
def dq_gemm_engaged() -> int:
323+
return _dq_gemm_engaged
324+
325+
326+
def affine_dq_gemm_min_m() -> int | None:
327+
raw = os.environ.get("FASTVIDEO_MLX_DQ_GEMM", "1").strip().lower()
328+
if raw in {"", "0", "off", "false", "no"}:
329+
return None
330+
if raw in {"1", "on", "true", "yes"}:
331+
return MLX_AFFINE_DQ_GEMM_DEFAULT_MIN_M
332+
try:
333+
value = int(raw)
334+
except ValueError:
335+
return MLX_AFFINE_DQ_GEMM_DEFAULT_MIN_M
336+
if value <= 0:
337+
return None
338+
return value
339+
340+
341+
def _matmul_leading_rows(x) -> int:
342+
last = int(x.shape[-1]) if x.ndim else 0
343+
if last <= 0:
344+
return 0
345+
return int(x.size) // last
346+
347+
348+
def _quantized_linear(x, weight: QuantizedMatrix):
307349
import mlx.core as mx
308350

309-
if isinstance(weight, QuantizedMatrix):
310-
y = mx.quantized_matmul(
311-
x,
351+
global _dq_gemm_engaged, _dq_gemm_logged
352+
spec = weight.spec
353+
min_m = affine_dq_gemm_min_m()
354+
rows = _matmul_leading_rows(x)
355+
if (min_m is not None and spec.mode == "affine" and spec.bits in _AFFINE_DQ_GEMM_BITS
356+
and spec.group_size is not None and rows >= min_m):
357+
dequantized = mx.dequantize(
312358
weight.weight,
313359
weight.scales,
314360
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
361+
group_size=spec.group_size,
362+
bits=spec.bits,
363+
mode=spec.mode,
364+
dtype=x.dtype,
365+
)
366+
y = (x @ dequantized.T).astype(x.dtype)
367+
_dq_gemm_engaged += 1
368+
if not _dq_gemm_logged:
369+
_dq_gemm_logged = True
370+
logger.info("affine dequant+GEMM engaged (rows=%d, floor=%d, bits=%s)", rows, min_m, spec.bits)
371+
return y
372+
return mx.quantized_matmul(
373+
x,
374+
weight.weight,
375+
weight.scales,
376+
weight.biases,
377+
transpose=True,
378+
group_size=spec.group_size,
379+
bits=spec.bits,
380+
mode=spec.mode,
381+
).astype(x.dtype)
382+
383+
384+
def linear(x, weight, bias=None):
385+
y = _quantized_linear(x, weight) if isinstance(weight, QuantizedMatrix) else x @ weight.T
322386
if bias is not None:
323387
y = y + bias
324388
return y
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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+
22+
AFFINE_BITS = (2, 3, 4, 5, 6, 8)
23+
GROUP_SIZES = (32, 64, 128)
24+
25+
26+
def _qmm(x, weight):
27+
return mx.quantized_matmul(
28+
x,
29+
weight.weight,
30+
weight.scales,
31+
weight.biases,
32+
transpose=True,
33+
group_size=weight.spec.group_size,
34+
bits=weight.spec.bits,
35+
mode=weight.spec.mode,
36+
).astype(x.dtype)
37+
38+
39+
def _try_quantize(out_features: int, in_features: int, bits: int, group_size: int):
40+
spec = MLXQuantizationSpec(mode="affine", bits=bits, group_size=group_size)
41+
weight = mx.random.normal((out_features, in_features)).astype(mx.bfloat16)
42+
try:
43+
quantized = quantize_matrix(weight, spec)
44+
mx.eval(quantized.weight, quantized.scales, quantized.biases)
45+
return quantized
46+
except Exception as exc: # noqa: BLE001 - MLX support varies by version.
47+
pytest.skip(f"affine bits={bits} group_size={group_size} unsupported: {exc}")
48+
49+
50+
def _rel_l2(a, b) -> float:
51+
left = np.asarray(a.astype(mx.float32))
52+
right = np.asarray(b.astype(mx.float32))
53+
denom = max(float(np.linalg.norm(left)), 1e-12)
54+
return float(np.linalg.norm(left - right) / denom)
55+
56+
57+
@pytest.mark.parametrize("bits", AFFINE_BITS)
58+
@pytest.mark.parametrize("group_size", GROUP_SIZES)
59+
def test_dq_gemm_matches_qmm_for_supported_bit_widths(bits: int, group_size: int, monkeypatch: pytest.MonkeyPatch) -> None:
60+
monkeypatch.setenv("FASTVIDEO_MLX_DQ_GEMM", "8")
61+
reset_dq_gemm_telemetry()
62+
in_features = group_size * 4
63+
out_features = group_size * 2
64+
quantized = _try_quantize(out_features, in_features, bits, group_size)
65+
x = mx.random.normal((16, in_features)).astype(mx.bfloat16)
66+
mx.eval(x)
67+
before = dq_gemm_engaged()
68+
got = linear(x, quantized)
69+
ref = _qmm(x, quantized)
70+
mx.eval(got, ref)
71+
assert dq_gemm_engaged() == before + 1
72+
rel = _rel_l2(got, ref)
73+
ref_np = np.asarray(ref.astype(mx.float32))
74+
got_np = np.asarray(got.astype(mx.float32))
75+
scale = max(float(np.max(np.abs(ref_np))), 1e-3)
76+
assert rel < 2e-2, rel
77+
assert float(np.max(np.abs(got_np - ref_np))) / scale < 0.08
78+
79+
80+
def test_dq_gemm_with_bias_and_batched_rows(monkeypatch: pytest.MonkeyPatch) -> None:
81+
monkeypatch.setenv("FASTVIDEO_MLX_DQ_GEMM", "4")
82+
reset_dq_gemm_telemetry()
83+
quantized = _try_quantize(64, 128, bits=6, group_size=64)
84+
x = mx.random.normal((2, 8, 128)).astype(mx.bfloat16)
85+
bias = mx.random.normal((64, )).astype(mx.bfloat16)
86+
mx.eval(x, bias)
87+
got = linear(x, quantized, bias)
88+
ref = _qmm(x, quantized) + bias
89+
mx.eval(got, ref)
90+
assert dq_gemm_engaged() == 1
91+
assert _rel_l2(got, ref) < 2e-2
92+
# transpose=True contract: output last dim is out_features.
93+
assert got.shape == (2, 8, 64)
94+
95+
96+
def test_dq_gemm_stays_on_qmm_below_threshold(monkeypatch: pytest.MonkeyPatch) -> None:
97+
monkeypatch.setenv("FASTVIDEO_MLX_DQ_GEMM", "768")
98+
reset_dq_gemm_telemetry()
99+
quantized = _try_quantize(64, 128, bits=6, group_size=64)
100+
x = mx.random.normal((32, 128)).astype(mx.bfloat16)
101+
mx.eval(x)
102+
got = linear(x, quantized)
103+
ref = _qmm(x, quantized)
104+
mx.eval(got, ref)
105+
assert dq_gemm_engaged() == 0
106+
np.testing.assert_array_equal(np.asarray(got.astype(mx.float32)), np.asarray(ref.astype(mx.float32)))
107+
108+
109+
def test_dq_gemm_env_zero_disables_dispatch(monkeypatch: pytest.MonkeyPatch) -> None:
110+
monkeypatch.setenv("FASTVIDEO_MLX_DQ_GEMM", "0")
111+
assert affine_dq_gemm_min_m() is None
112+
reset_dq_gemm_telemetry()
113+
quantized = _try_quantize(64, 128, bits=6, group_size=64)
114+
x = mx.random.normal((1024, 128)).astype(mx.bfloat16)
115+
mx.eval(x)
116+
linear(x, quantized)
117+
mx.eval(x)
118+
assert dq_gemm_engaged() == 0
119+
120+
121+
def test_non_affine_weights_stay_on_quantized_matmul(monkeypatch: pytest.MonkeyPatch) -> None:
122+
monkeypatch.setenv("FASTVIDEO_MLX_DQ_GEMM", "1")
123+
reset_dq_gemm_telemetry()
124+
spec = MLXQuantizationSpec(mode="mxfp8")
125+
weight = mx.random.normal((64, 64)).astype(mx.bfloat16)
126+
try:
127+
quantized = quantize_matrix(weight, spec)
128+
mx.eval(quantized.weight, quantized.scales)
129+
except Exception as exc: # noqa: BLE001
130+
pytest.skip(f"mxfp8 unsupported: {exc}")
131+
x = mx.random.normal((1024, 64)).astype(mx.bfloat16)
132+
mx.eval(x)
133+
got = linear(x, quantized)
134+
mx.eval(got)
135+
assert dq_gemm_engaged() == 0
136+
assert got.shape == (1024, 64)
137+
138+
139+
def test_default_floor_is_measured_768(monkeypatch: pytest.MonkeyPatch) -> None:
140+
monkeypatch.setenv("FASTVIDEO_MLX_DQ_GEMM", "1")
141+
assert affine_dq_gemm_min_m() == 768
142+
monkeypatch.setenv("FASTVIDEO_MLX_DQ_GEMM", "2048")
143+
assert affine_dq_gemm_min_m() == 2048
144+
monkeypatch.delenv("FASTVIDEO_MLX_DQ_GEMM", raising=False)
145+
os.environ.pop("FASTVIDEO_MLX_DQ_GEMM", None)
146+
# Default with unset env is on at the measured floor.
147+
monkeypatch.delenv("FASTVIDEO_MLX_DQ_GEMM", raising=False)
148+
if "FASTVIDEO_MLX_DQ_GEMM" in os.environ:
149+
pytest.skip("parent environment pinned FASTVIDEO_MLX_DQ_GEMM")
150+
assert affine_dq_gemm_min_m() == 768

0 commit comments

Comments
 (0)