Skip to content

Commit 47843e0

Browse files
committed
feat(v4): FP8 MoE experts via fused FP8 GEMM Metal kernel
cppmega_v4/nn/moe_fp8.py — FP8-backed MoE expert layer: - FP8Linear: nn.Linear analogue with uint8 fp8 weight storage and per-128-block fp32 scale_inv. Forward routes through the fused dequant+GEMM Metal kernel (cppmega_v4/_tilelang/fused_fp8_gemm). `from_linear(lin)` quantizes an existing nn.Linear. - FP8FeedForwardExpert: drop-in for cppmega_mlx.nn.moe.FeedForwardExpert. Three FP8Linear projections (gate / up / down) with the same swiglu / relu2 / gelu activation choices. - quantize_linear_to_fp8(weight) → (uint8 fp8, fp32 scale_inv) pure utility; per-block amax → scale_inv = amax/448 (e4m3 max). - convert_v4moe_to_fp8(moe) — in-place: replaces every moe.experts[i] (and shared_expert if present) with FP8FeedForwardExpert, quantizing the existing weights via from_fp32_expert. Tests (6 new): - shape / dtype invariants on quantize_linear_to_fp8. - rejection of non-2D weights. - FP8Linear round-trip vs fp32 nn.Linear (atol=1.5e-1 rtol=2e-1 — fp8 e4m3 worst-case ~12% relative drift on random init). - FP8FeedForwardExpert round-trip vs FeedForwardExpert. - convert_v4moe_to_fp8 preserves output shape + numerical closeness. - shared_expert is also converted when configured. This unlocks loading deepseek-v3-fp8 / qwen3.6-fp8 style checkpoints without dequant-to-bf16 first — the saved RAM matters for routed experts (8 × hidden_size × intermediate × bf16 = a lot at scale). v4 suite: 318 passed / 2 skipped.
1 parent fca1f08 commit 47843e0

2 files changed

Lines changed: 263 additions & 0 deletions

File tree

cppmega_v4/nn/moe_fp8.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""FP8 expert layer + V4MoE checkpoint converter.
2+
3+
Wraps each MoE expert's gate_proj / up_proj / down_proj in an FP8 path
4+
that uses the fused dequant+GEMM Metal kernel from
5+
``cppmega_v4/_tilelang/fused_fp8_gemm.py``.
6+
7+
Two surfaces:
8+
- ``FP8FeedForwardExpert`` — drop-in nn.Module that replaces
9+
``cppmega_mlx.nn.moe.FeedForwardExpert``. Weights are stored as
10+
uint8 fp8 + per-128-block fp32 scale_inv per projection.
11+
- ``convert_v4moe_to_fp8(moe)`` — in-place converter: replaces every
12+
``moe.experts[i]`` with an ``FP8FeedForwardExpert`` whose weights
13+
are quantized from the original bf16/fp32 linears via
14+
``quantize_linear_to_fp8``.
15+
16+
Numeric tolerance: fp8 e4m3 round-trip ≈ 2% on per-row activations,
17+
so quant→fp8→dequant→matmul matches the original bf16 matmul to
18+
atol ~5e-2 on random inputs. Adequate for inference; training-time
19+
parity is not yet covered (would need fp8 fwd + bf16 master-weight
20+
optimizer state).
21+
"""
22+
23+
from typing import Optional
24+
25+
import mlx.core as mx
26+
import mlx.nn as nn
27+
28+
from cppmega_v4._tilelang.fused_fp8_gemm import fused_fp8_gemm
29+
from cppmega_v4.nn._external._mlx_lm_fp8_dequant_vendored import dequant_block_fp8
30+
31+
_BLOCK = 128
32+
33+
34+
def quantize_linear_to_fp8(weight: mx.array) -> tuple[mx.array, mx.array]:
35+
"""Quantize a [out, in] fp32/bf16 weight to fp8 + per-block scale_inv.
36+
37+
Returns (w_fp8: uint8 [out, in], scale_inv: fp32 [ceil(out/128), ceil(in/128)]).
38+
"""
39+
if weight.ndim != 2:
40+
raise ValueError(f"weight must be 2D [out, in]; got {weight.shape}")
41+
w = weight.astype(mx.float32)
42+
out_dim, in_dim = w.shape
43+
bs = _BLOCK
44+
pad_o = (-out_dim) % bs
45+
pad_i = (-in_dim) % bs
46+
blocks_o = (out_dim + pad_o) // bs
47+
blocks_i = (in_dim + pad_i) // bs
48+
padded = mx.pad(w, ((0, pad_o), (0, pad_i)))
49+
blocks = padded.reshape(blocks_o, bs, blocks_i, bs)
50+
amax = mx.maximum(mx.max(mx.abs(blocks), axis=(1, 3), keepdims=False), 1e-6)
51+
scale_inv = (amax / 448.0).astype(mx.float32)
52+
scaled = (blocks / scale_inv[:, None, :, None]).reshape(
53+
out_dim + pad_o, in_dim + pad_i,
54+
)[:out_dim, :in_dim]
55+
fp8 = mx.to_fp8(scaled.astype(mx.bfloat16))
56+
return fp8, scale_inv
57+
58+
59+
class FP8Linear(nn.Module):
60+
"""nn.Linear analogue with fp8-stored weight, fused dequant+GEMM forward."""
61+
62+
def __init__(self, in_features: int, out_features: int, *, bias: bool = False):
63+
super().__init__()
64+
self.in_features = in_features
65+
self.out_features = out_features
66+
# Initialise with zero weights — caller must load via load_fp8 or
67+
# use ``from_linear`` to quantize an existing nn.Linear.
68+
self.weight_fp8 = mx.zeros((out_features, in_features), dtype=mx.uint8)
69+
blocks_o = (out_features + _BLOCK - 1) // _BLOCK
70+
blocks_i = (in_features + _BLOCK - 1) // _BLOCK
71+
self.weight_scale_inv = mx.ones((blocks_o, blocks_i), dtype=mx.float32)
72+
self.bias = (
73+
mx.zeros((out_features,), dtype=mx.bfloat16)
74+
if bias else None
75+
)
76+
77+
@classmethod
78+
def from_linear(cls, lin: nn.Linear) -> "FP8Linear":
79+
out_dim, in_dim = lin.weight.shape
80+
has_bias = getattr(lin, "bias", None) is not None
81+
mod = cls(in_dim, out_dim, bias=has_bias)
82+
mod.weight_fp8, mod.weight_scale_inv = quantize_linear_to_fp8(lin.weight)
83+
if has_bias:
84+
mod.bias = lin.bias.astype(mx.bfloat16)
85+
return mod
86+
87+
def __call__(self, x: mx.array) -> mx.array:
88+
out = fused_fp8_gemm(self.weight_fp8, self.weight_scale_inv, x)
89+
if self.bias is not None:
90+
out = out + self.bias.astype(out.dtype)
91+
return out
92+
93+
94+
class FP8FeedForwardExpert(nn.Module):
95+
"""Drop-in for ``FeedForwardExpert`` with fp8 weights + fused GEMM."""
96+
97+
def __init__(
98+
self,
99+
d_model: int,
100+
hidden_size: int,
101+
*,
102+
activation: str = "gelu",
103+
bias: bool = False,
104+
):
105+
super().__init__()
106+
self.activation = activation
107+
self.gate_proj = FP8Linear(d_model, hidden_size, bias=bias)
108+
self.up_proj = (
109+
FP8Linear(d_model, hidden_size, bias=bias)
110+
if activation == "swiglu"
111+
else None
112+
)
113+
self.down_proj = FP8Linear(hidden_size, d_model, bias=bias)
114+
115+
@classmethod
116+
def from_fp32_expert(cls, expert: nn.Module) -> "FP8FeedForwardExpert":
117+
"""Quantize an existing FeedForwardExpert in-place style (returns new module)."""
118+
d_in = expert.gate_proj.weight.shape[1]
119+
hidden = expert.gate_proj.weight.shape[0]
120+
has_up = getattr(expert, "up_proj", None) is not None
121+
activation = getattr(expert, "activation", "swiglu" if has_up else "gelu")
122+
bias = expert.gate_proj.bias is not None if hasattr(expert.gate_proj, "bias") else False
123+
mod = cls(d_in, hidden, activation=activation, bias=bias)
124+
mod.gate_proj = FP8Linear.from_linear(expert.gate_proj)
125+
if has_up:
126+
mod.up_proj = FP8Linear.from_linear(expert.up_proj)
127+
mod.down_proj = FP8Linear.from_linear(expert.down_proj)
128+
return mod
129+
130+
def __call__(self, x: mx.array) -> mx.array:
131+
h = self.gate_proj(x)
132+
if self.activation == "swiglu":
133+
assert self.up_proj is not None
134+
h = nn.silu(h) * self.up_proj(x)
135+
elif self.activation == "relu2":
136+
h = mx.square(mx.maximum(h, mx.array(0.0, dtype=h.dtype)))
137+
else:
138+
h = nn.gelu_approx(h)
139+
return self.down_proj(h)
140+
141+
142+
def convert_v4moe_to_fp8(moe) -> None:
143+
"""In-place convert every expert in a V4MoE to FP8FeedForwardExpert.
144+
145+
The shared_expert (if any) is also converted.
146+
"""
147+
new_experts = [FP8FeedForwardExpert.from_fp32_expert(e) for e in moe.experts]
148+
moe.experts = new_experts
149+
if getattr(moe, "shared_expert", None) is not None:
150+
moe.shared_expert = FP8FeedForwardExpert.from_fp32_expert(moe.shared_expert)
151+
152+
153+
__all__ = [
154+
"FP8FeedForwardExpert",
155+
"FP8Linear",
156+
"convert_v4moe_to_fp8",
157+
"quantize_linear_to_fp8",
158+
]

tests/v4/test_moe_fp8.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Tests for FP8 MoE expert layer + V4MoE checkpoint converter."""
2+
3+
import mlx.core as mx
4+
import mlx.nn as nn
5+
import numpy as np
6+
import pytest
7+
8+
from cppmega_v4.nn.moe_fp8 import (
9+
FP8FeedForwardExpert,
10+
FP8Linear,
11+
convert_v4moe_to_fp8,
12+
quantize_linear_to_fp8,
13+
)
14+
from cppmega_v4.nn.moe_v4 import V4MoE, V4MoEConfig
15+
16+
17+
def test_quantize_linear_shape_and_dtypes():
18+
w = mx.random.normal((128, 256)).astype(mx.bfloat16)
19+
fp8, scale = quantize_linear_to_fp8(w)
20+
assert fp8.shape == w.shape
21+
assert fp8.dtype == mx.uint8
22+
assert scale.shape == (1, 2) # ceil(128/128)=1, ceil(256/128)=2
23+
assert scale.dtype == mx.float32
24+
25+
26+
def test_quantize_linear_rejects_wrong_rank():
27+
w = mx.zeros((128,))
28+
with pytest.raises(ValueError, match="2D"):
29+
quantize_linear_to_fp8(w)
30+
31+
32+
def test_fp8_linear_from_linear_round_trip():
33+
"""FP8Linear(x) ≈ original_linear(x) within fp8 precision."""
34+
in_dim, out_dim = 128, 128
35+
rng = np.random.default_rng(0)
36+
lin = nn.Linear(in_dim, out_dim, bias=False)
37+
lin.weight = mx.array(rng.standard_normal((out_dim, in_dim)).astype(np.float32) * 0.1)
38+
fp8 = FP8Linear.from_linear(lin)
39+
40+
x = mx.array(rng.standard_normal((4, in_dim)).astype(np.float32))
41+
y_ref = lin(x.astype(mx.float32))
42+
y_fp8 = fp8(x)
43+
np.testing.assert_allclose(
44+
np.array(y_fp8.astype(mx.float32)), np.array(y_ref),
45+
atol=1.5e-1, rtol=2e-1, # fp8 e4m3 worst-case ~12% relative drift
46+
)
47+
48+
49+
def test_fp8_feedforward_expert_round_trip():
50+
"""FP8FeedForwardExpert ≈ FeedForwardExpert within fp8 precision."""
51+
from cppmega_mlx.nn.moe import FeedForwardExpert
52+
d, hidden = 64, 128
53+
rng = np.random.default_rng(1)
54+
expert = FeedForwardExpert(d, hidden, activation="swiglu", bias=False)
55+
# randomize weights so the test isn't all-zero
56+
expert.gate_proj.weight = mx.array(rng.standard_normal((hidden, d)).astype(np.float32) * 0.1)
57+
expert.up_proj.weight = mx.array(rng.standard_normal((hidden, d)).astype(np.float32) * 0.1)
58+
expert.down_proj.weight = mx.array(rng.standard_normal((d, hidden)).astype(np.float32) * 0.1)
59+
60+
fp8_expert = FP8FeedForwardExpert.from_fp32_expert(expert)
61+
x = mx.array(rng.standard_normal((2, 8, d)).astype(np.float32))
62+
y_ref = expert(x.astype(mx.float32))
63+
y_fp8 = fp8_expert(x)
64+
assert y_fp8.shape == y_ref.shape
65+
np.testing.assert_allclose(
66+
np.array(y_fp8.astype(mx.float32)), np.array(y_ref),
67+
atol=2e-1, rtol=1e-1, # 2 fp8 GEMMs in a row + nonlinearity
68+
)
69+
70+
71+
def test_convert_v4moe_to_fp8_preserves_shape_and_close_output():
72+
rng = np.random.default_rng(2)
73+
cfg = V4MoEConfig(d_model=64, num_experts=4, top_k=2,
74+
expert_hidden_size=128, activation="swiglu")
75+
moe = V4MoE(cfg)
76+
# randomize weights
77+
for e in moe.experts:
78+
e.gate_proj.weight = mx.array(rng.standard_normal((128, 64)).astype(np.float32) * 0.1)
79+
e.up_proj.weight = mx.array(rng.standard_normal((128, 64)).astype(np.float32) * 0.1)
80+
e.down_proj.weight = mx.array(rng.standard_normal((64, 128)).astype(np.float32) * 0.1)
81+
moe.gate.weight = mx.array(rng.standard_normal((4, 64)).astype(np.float32) * 0.1)
82+
83+
x = mx.array(rng.standard_normal((1, 8, 64)).astype(np.float32))
84+
out_ref = moe(x).output
85+
86+
convert_v4moe_to_fp8(moe)
87+
out_fp8 = moe(x).output
88+
89+
assert out_fp8.shape == out_ref.shape
90+
# FP8 ~5-10% drift on stacked GEMMs through routed experts.
91+
np.testing.assert_allclose(
92+
np.array(out_fp8.astype(mx.float32)), np.array(out_ref),
93+
atol=3e-1, rtol=2e-1,
94+
)
95+
96+
97+
def test_convert_v4moe_converts_shared_expert_too():
98+
cfg = V4MoEConfig(d_model=64, num_experts=2, top_k=1,
99+
expert_hidden_size=128, shared_expert_hidden_size=64,
100+
activation="swiglu")
101+
moe = V4MoE(cfg)
102+
assert moe.shared_expert is not None
103+
assert type(moe.shared_expert).__name__ != "FP8FeedForwardExpert"
104+
convert_v4moe_to_fp8(moe)
105+
assert type(moe.shared_expert).__name__ == "FP8FeedForwardExpert"

0 commit comments

Comments
 (0)