Skip to content

Commit 324a17d

Browse files
committed
feat(v4): fused FP8 GEMM Metal kernel + StreamingPoolCache for CSA/HCA
Two next-level optimizations: 1. cppmega_v4/_tilelang/fused_fp8_gemm.py — fused dequant + GEMM Metal kernel. Replaces the "dequant to bf16, then matmul" round-trip in LightningIndexerFP8._wq_b_apply with one MSL pass per output column. Per-block (128x128) scale_inv lookup is amortized across 128 K-elements; per-thread accumulator stays in fp32 registers. FP8 e4m3 decoder is inlined into MSL (sign/expt/mant bit-fields, subnormal & NaN handling) so no MLX op call inside the kernel. Tests (5): shape, fused-vs-unfused parity within fp8 precision (atol 2e-2 rtol 5e-2), dtype/shape rejection, non-128-aligned shapes. 2. cppmega_v4/nn/streaming_kv_cache.py — StreamingPoolCache for CSA/HCA/NSA-Compress branches. Avoids the per-forward recompute of mean-pool over historical tokens during decode. Maintains: - completed [B, n_super, H, D] of finalized super-tokens - partial [B, H, D] sum + count of the in-progress block `append(k, v)` walks tokens, flushing when a block fills. `snapshot(include_partial=True/False)` returns the current view. Tests (7): construct/append validation, streaming-vs-oneshot mean-pool parity, partial-block finalization, total/n_super counters, reset clears state, single-token append (decode-style streaming). Plus: two background agents spawned to extend triton_frontend op_mapping (tt.dot/tt.exp emitters for FLA chunk kernel) and to build the C++ PtrAnalysis shim — both work in our /Volumes/external/sources/tilelang fork. v4 suite: 285 passed / 2 skipped.
1 parent 3bcc95e commit 324a17d

4 files changed

Lines changed: 492 additions & 0 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Fused FP8 GEMM Metal kernel — block-fp8 weight × bf16/fp32 activation.
2+
3+
Used by ROI 7 (Lightning Indexer FP8) to fuse dequant + GEMM instead of
4+
materializing the full bf16 weight tile on every forward.
5+
6+
Layout:
7+
- W_fp8: [M, K] uint8 (e4m3 storage); each [128, 128] block has a single
8+
fp32 scale_inv stored in W_scale_inv: [ceil(M/128), ceil(K/128)].
9+
- A: [..., K] fp32/bf16 (caller's choice — internally promoted to fp32).
10+
- Out: [..., M] same dtype as A.
11+
12+
The kernel computes (per output column m):
13+
14+
acc = 0
15+
for k in [0, K):
16+
block_m = m // 128
17+
block_k = k // 128
18+
acc += dequant(W_fp8[m, k], W_scale_inv[block_m, block_k]) * A[..., k]
19+
Out[..., m] = acc
20+
21+
One thread per output column m × batch row. Block_size=128 amortizes the
22+
scale_inv lookup across 128 K-elements per (m, batch_row).
23+
"""
24+
25+
from typing import Tuple
26+
27+
import mlx.core as mx
28+
29+
from cppmega_v4._tilelang._kernel_cache import get_or_build_kernel
30+
31+
_BLOCK = 128
32+
33+
34+
def fused_fp8_gemm(
35+
w_fp8: mx.array,
36+
w_scale_inv: mx.array,
37+
a: mx.array,
38+
) -> mx.array:
39+
"""Fused dequant + GEMM: out = a @ W.T where W = dequant(w_fp8, w_scale_inv).
40+
41+
Args:
42+
w_fp8: [M, K] uint8 fp8 weight.
43+
w_scale_inv: [ceil(M/128), ceil(K/128)] fp32 per-block inverse-scale.
44+
a: [..., K] bf16/fp32 activation.
45+
46+
Returns:
47+
out: [..., M] same dtype as a.
48+
"""
49+
if w_fp8.dtype != mx.uint8:
50+
raise TypeError(f"w_fp8 must be uint8 (fp8 storage); got {w_fp8.dtype}")
51+
if w_fp8.ndim != 2:
52+
raise ValueError(f"w_fp8 must be 2D [M, K]; got {w_fp8.shape}")
53+
M, K = w_fp8.shape
54+
if a.shape[-1] != K:
55+
raise ValueError(f"a.shape[-1] ({a.shape[-1]}) must equal w_fp8.shape[1] ({K})")
56+
bs = _BLOCK
57+
blocks_m_expected = (M + bs - 1) // bs
58+
blocks_k_expected = (K + bs - 1) // bs
59+
if w_scale_inv.shape != (blocks_m_expected, blocks_k_expected):
60+
raise ValueError(
61+
f"w_scale_inv shape {w_scale_inv.shape} != expected "
62+
f"({blocks_m_expected}, {blocks_k_expected}) for W={M, K} with block={bs}"
63+
)
64+
65+
out_dtype = a.dtype
66+
a_fp32 = a.astype(mx.float32)
67+
# Flatten activations to [N, K] for the kernel.
68+
leading = a.shape[:-1]
69+
n = 1
70+
for d in leading:
71+
n *= d
72+
a_flat = a_fp32.reshape(n, K)
73+
74+
blocks_m = blocks_m_expected
75+
blocks_k = blocks_k_expected
76+
w_flat = w_fp8.reshape(-1) # [M*K]
77+
s_flat = w_scale_inv.reshape(-1) # [blocks_m * blocks_k]
78+
a_flat_1d = a_flat.reshape(-1) # [N*K]
79+
80+
# Convert per-row scale_inv lookup to a flat index inside the kernel.
81+
# Each thread = (m, row); inner loop over k, reads s_flat[(m/128)*blocks_k + (k/128)]
82+
# and the corresponding fp8 byte from w_flat[m*K + k].
83+
source = f"""
84+
uint m = thread_position_in_grid.x;
85+
uint row = thread_position_in_grid.y;
86+
if (m >= {M}u || row >= {n}u) return;
87+
88+
uint block_m = m / {bs}u;
89+
float acc = 0.0f;
90+
91+
// Inner K-loop: unrolled per-block to amortize scale_inv lookup.
92+
for (uint kb = 0; kb < {blocks_k}u; ++kb) {{
93+
float scale = s_flat[block_m * {blocks_k}u + kb];
94+
uint k_start = kb * {bs}u;
95+
uint k_end_full = k_start + {bs}u;
96+
uint k_end = k_end_full < {K}u ? k_end_full : {K}u;
97+
for (uint k = k_start; k < k_end; ++k) {{
98+
// mx.from_fp8 conversion: e4m3 fp8 byte → fp32.
99+
// Reproduce the bit-decoder inline so this kernel stays
100+
// self-contained (no MLX op call inside MSL).
101+
uint byte = (uint)w_flat[m * {K}u + k];
102+
int sign = (byte >> 7) & 0x1;
103+
int expt = (byte >> 3) & 0xF;
104+
int mant = byte & 0x7;
105+
float val;
106+
if (expt == 0) {{
107+
// subnormal: val = (-1)^s * 2^-6 * (mant/8)
108+
val = (float)mant / 8.0f * 0.015625f; // 2^-6 = 1/64
109+
}} else if (expt == 0xF && mant == 0x7) {{
110+
// NaN
111+
val = 0.0f; // treat as zero in GEMM accumulation
112+
}} else {{
113+
// normal: val = (-1)^s * 2^(expt-7) * (1 + mant/8)
114+
float mantissa = 1.0f + (float)mant / 8.0f;
115+
int bias_exp = expt - 7;
116+
// 2^bias_exp via metal::ldexp.
117+
val = metal::ldexp(mantissa, bias_exp);
118+
}}
119+
if (sign) val = -val;
120+
acc += val * scale * a_flat[row * {K}u + k];
121+
}}
122+
}}
123+
out[row * {M}u + m] = acc;
124+
"""
125+
126+
name = f"v4_fused_fp8_gemm_{M}_{K}_{n}"
127+
kernel = get_or_build_kernel(
128+
name=name,
129+
input_names=["w_flat", "s_flat", "a_flat"],
130+
output_names=["out"],
131+
source=source,
132+
)
133+
134+
grid = (M, n, 1)
135+
tg_x = min(M, 32)
136+
threadgroup = (tg_x, 1, 1)
137+
138+
(out_flat,) = kernel(
139+
inputs=[w_flat, s_flat, a_flat_1d],
140+
output_shapes=[(n * M,)],
141+
output_dtypes=[mx.float32],
142+
grid=grid,
143+
threadgroup=threadgroup,
144+
)
145+
out = out_flat.reshape(*leading, M).astype(out_dtype)
146+
return out
147+
148+
149+
__all__ = ["fused_fp8_gemm"]
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Streaming KV cache for CSA / HCA / NSA-Compress branches.
2+
3+
Avoids recomputing the mean-pool over historical tokens on every forward.
4+
Maintains a running compressed K/V buffer of super-tokens (size ⌈S_seen/m⌉)
5+
plus a partial super-token-in-progress with its accumulated mean and count.
6+
7+
Three operations:
8+
cache = StreamingPoolCache(m=m, n_heads=H, head_dim=D, dtype=mx.float32)
9+
cache.append(new_k, new_v) # append [B, T_new, H, D]
10+
k_super, v_super = cache.snapshot() # [B, n_super, H, D] — current view
11+
12+
Pure-MLX implementation (no Metal kernel needed). A future Metal/TileLang
13+
fused kernel could maintain the running mean in shared memory; current
14+
impl is allocation-friendly enough for inference on Apple Silicon.
15+
"""
16+
17+
from typing import Optional, Tuple
18+
19+
import mlx.core as mx
20+
21+
22+
class StreamingPoolCache:
23+
"""Per-(B, H) running mean-pool over m tokens for compressed-attention.
24+
25+
State:
26+
- completed: [B, n_super, H, D] fp32, the per-block means already finalized.
27+
- partial_sum: [B, H, D] fp32, sum-so-far of the in-progress super-token.
28+
- partial_count: int — number of tokens already added to partial_sum.
29+
30+
Calling `snapshot()` returns the completed block + the in-progress block
31+
finalized as `partial_sum / partial_count` so the caller sees a coherent
32+
[B, n_super_total, H, D] view.
33+
"""
34+
35+
def __init__(
36+
self,
37+
*,
38+
m: int,
39+
batch: int,
40+
n_heads: int,
41+
head_dim: int,
42+
dtype: mx.Dtype = mx.float32,
43+
):
44+
if m <= 0:
45+
raise ValueError(f"m must be positive, got {m}")
46+
if batch <= 0 or n_heads <= 0 or head_dim <= 0:
47+
raise ValueError("batch / n_heads / head_dim must all be positive")
48+
self.m = m
49+
self.batch = batch
50+
self.n_heads = n_heads
51+
self.head_dim = head_dim
52+
self.dtype = dtype
53+
self._completed_k: Optional[mx.array] = None # [B, n_super, H, D]
54+
self._completed_v: Optional[mx.array] = None
55+
self._partial_k = mx.zeros((batch, n_heads, head_dim), dtype=dtype)
56+
self._partial_v = mx.zeros((batch, n_heads, head_dim), dtype=dtype)
57+
self._partial_count = 0
58+
self._n_super = 0
59+
self._total_tokens = 0
60+
61+
@property
62+
def total_tokens(self) -> int:
63+
return self._total_tokens
64+
65+
@property
66+
def n_super_completed(self) -> int:
67+
return self._n_super
68+
69+
def append(self, k: mx.array, v: mx.array) -> None:
70+
"""Append [B, T_new, H, D] new K/V tokens to the running cache."""
71+
if k.shape != v.shape:
72+
raise ValueError(f"k.shape ({k.shape}) != v.shape ({v.shape})")
73+
if k.ndim != 4:
74+
raise ValueError(f"expected [B, T, H, D]; got {k.shape}")
75+
b, t, h, d = k.shape
76+
if b != self.batch or h != self.n_heads or d != self.head_dim:
77+
raise ValueError(
78+
f"k shape {k.shape} incompatible with cache "
79+
f"(B={self.batch}, H={self.n_heads}, D={self.head_dim})"
80+
)
81+
k = k.astype(self.dtype)
82+
v = v.astype(self.dtype)
83+
84+
# Walk tokens one at a time: simpler than partial-block arithmetic
85+
# because we need to flush completed super-tokens mid-batch.
86+
token_idx = 0
87+
while token_idx < t:
88+
tokens_to_fill = self.m - self._partial_count
89+
tokens_available = t - token_idx
90+
take = min(tokens_to_fill, tokens_available)
91+
# Accumulate take tokens into partial.
92+
chunk_k = k[:, token_idx : token_idx + take] # [B, take, H, D]
93+
chunk_v = v[:, token_idx : token_idx + take]
94+
self._partial_k = self._partial_k + chunk_k.sum(axis=1)
95+
self._partial_v = self._partial_v + chunk_v.sum(axis=1)
96+
self._partial_count += take
97+
token_idx += take
98+
self._total_tokens += take
99+
if self._partial_count == self.m:
100+
# Flush completed super-token.
101+
mean_k = (self._partial_k / float(self.m))[:, None, :, :]
102+
mean_v = (self._partial_v / float(self.m))[:, None, :, :]
103+
if self._completed_k is None:
104+
self._completed_k = mean_k
105+
self._completed_v = mean_v
106+
else:
107+
self._completed_k = mx.concatenate(
108+
[self._completed_k, mean_k], axis=1
109+
)
110+
self._completed_v = mx.concatenate(
111+
[self._completed_v, mean_v], axis=1
112+
)
113+
self._n_super += 1
114+
self._partial_k = mx.zeros_like(self._partial_k)
115+
self._partial_v = mx.zeros_like(self._partial_v)
116+
self._partial_count = 0
117+
118+
def snapshot(
119+
self, *, include_partial: bool = True,
120+
) -> Tuple[mx.array, mx.array]:
121+
"""Return current compressed K/V super-tokens [B, n_super_total, H, D].
122+
123+
With ``include_partial=True`` (default), the in-progress block is
124+
finalized as ``partial_sum / partial_count``. With False, only the
125+
completed super-tokens are returned.
126+
"""
127+
completed_k = (
128+
self._completed_k if self._completed_k is not None
129+
else mx.zeros((self.batch, 0, self.n_heads, self.head_dim),
130+
dtype=self.dtype)
131+
)
132+
completed_v = (
133+
self._completed_v if self._completed_v is not None
134+
else mx.zeros((self.batch, 0, self.n_heads, self.head_dim),
135+
dtype=self.dtype)
136+
)
137+
if include_partial and self._partial_count > 0:
138+
mean_k = (self._partial_k / float(self._partial_count))[:, None, :, :]
139+
mean_v = (self._partial_v / float(self._partial_count))[:, None, :, :]
140+
completed_k = mx.concatenate([completed_k, mean_k], axis=1)
141+
completed_v = mx.concatenate([completed_v, mean_v], axis=1)
142+
return completed_k, completed_v
143+
144+
def reset(self) -> None:
145+
"""Drop all state (start a new sequence)."""
146+
self._completed_k = None
147+
self._completed_v = None
148+
self._partial_k = mx.zeros((self.batch, self.n_heads, self.head_dim),
149+
dtype=self.dtype)
150+
self._partial_v = mx.zeros((self.batch, self.n_heads, self.head_dim),
151+
dtype=self.dtype)
152+
self._partial_count = 0
153+
self._n_super = 0
154+
self._total_tokens = 0
155+
156+
157+
__all__ = ["StreamingPoolCache"]

tests/v4/test_fused_fp8_gemm.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Tests for fused FP8 GEMM Metal kernel."""
2+
3+
import mlx.core as mx
4+
import numpy as np
5+
import pytest
6+
7+
from cppmega_v4._tilelang.fused_fp8_gemm import fused_fp8_gemm
8+
from cppmega_v4.nn._external._mlx_lm_fp8_dequant_vendored import dequant_block_fp8
9+
10+
11+
def _make_fp8(m, n, seed=0):
12+
rng = np.random.default_rng(seed)
13+
bs = 128
14+
blocks_m = (m + bs - 1) // bs
15+
blocks_n = (n + bs - 1) // bs
16+
scale_inv = mx.array(rng.uniform(0.5, 1.5, (blocks_m, blocks_n)).astype(np.float32))
17+
bf = mx.array(rng.standard_normal((m, n)).astype(np.float32) * 0.1).astype(mx.bfloat16)
18+
pad_b = (-m) % bs
19+
pad_s = (-n) % bs
20+
padded = mx.pad(bf.astype(mx.float32), ((0, pad_b), (0, pad_s)))
21+
blocks = padded.reshape(blocks_m, bs, blocks_n, bs)
22+
scaled = (blocks / scale_inv[:, None, :, None]).reshape(m + pad_b, n + pad_s)[:m, :n]
23+
fp8 = mx.to_fp8(scaled)
24+
return fp8, scale_inv
25+
26+
27+
def test_fused_fp8_gemm_shape():
28+
M, K, B = 128, 128, 4
29+
w, s = _make_fp8(M, K)
30+
a = mx.random.normal((B, K))
31+
out = fused_fp8_gemm(w, s, a)
32+
assert out.shape == (B, M)
33+
34+
35+
def test_fused_fp8_gemm_matches_dequant_then_matmul():
36+
"""Fused kernel must match the unfused dequant→matmul path within fp8 precision."""
37+
M, K, B = 128, 128, 8
38+
w, s = _make_fp8(M, K, seed=7)
39+
rng = np.random.default_rng(99)
40+
a = mx.array(rng.standard_normal((B, K)).astype(np.float32))
41+
42+
out_fused = fused_fp8_gemm(w, s, a)
43+
# Reference path: dequant then matmul.
44+
w_bf16 = dequant_block_fp8(w, s)
45+
out_ref = (a.astype(mx.float32) @ w_bf16.T.astype(mx.float32))
46+
47+
np.testing.assert_allclose(
48+
np.array(out_fused.astype(mx.float32)),
49+
np.array(out_ref),
50+
atol=2e-2, rtol=5e-2, # fp8 rounding inside the kernel
51+
)
52+
53+
54+
def test_fused_fp8_gemm_rejects_dtype_mismatch():
55+
M, K = 128, 128
56+
w = mx.zeros((M, K), dtype=mx.float32)
57+
s = mx.ones((1, 1), dtype=mx.float32)
58+
a = mx.zeros((1, K))
59+
with pytest.raises(TypeError, match="uint8"):
60+
fused_fp8_gemm(w, s, a)
61+
62+
63+
def test_fused_fp8_gemm_rejects_shape_mismatch():
64+
M, K = 128, 128
65+
w, s = _make_fp8(M, K)
66+
a = mx.zeros((1, K + 1))
67+
with pytest.raises(ValueError, match="a.shape"):
68+
fused_fp8_gemm(w, s, a)
69+
70+
71+
def test_fused_fp8_gemm_handles_non_block_aligned():
72+
"""Shapes not divisible by 128 must still produce correct (n, M) output."""
73+
M, K, B = 100, 200, 2
74+
w, s = _make_fp8(M, K)
75+
a = mx.random.normal((B, K))
76+
out = fused_fp8_gemm(w, s, a)
77+
assert out.shape == (B, M)
78+
assert np.all(np.isfinite(np.array(out)))

0 commit comments

Comments
 (0)