Skip to content

Commit f2f33a8

Browse files
committed
feat(v4): GDN Path C — TileLang DSL @T.prim_func → Metal via tvm_ffi
Mirrors cppmega_mlx/nn/_tilelang/mamba3_path_c.py structure: - Per-lane recurrent scan (B*H*V lanes), state held in registers as HEADDIM_K floats per lane. - Recurrence body matches the FLA naive: exp(g) decay, KS reduction along K, β-corrected delta, rank-1 outer add, q·h projection. - Imports `dispatch_lower` and `_msl_transform` from the host cppmega_mlx._tilelang infra (read-only — host templates untouched per the plugin invariant). - dispatch_lower(prim, target='metal', return_msl=True) for MSL extraction, then tilelang.compile(target=_as_metal_target('metal'), execution_backend='tvm_ffi', out_idx=[6, 7]) for the runtime artifact. Status & fallback: `_path_c_runtime_status()` probes both tilelang and the host MSL infra; when either is missing, the dispatch returns Path-A reference output. The status reason now names the lowering pipeline ("TileLang DSL → tilelang.compile(target='metal', execution_backend='tvm_ffi')") so failures are actionable. Tests (6 new): module import w/o tilelang, status text precision, runtime-status / dispatch-status round-trip, env-forced dispatch produces valid output, fallback parity vs Path A bit-exact, real prim_func parity (skipped when tilelang missing — runs on CUDA/CI box). v4 suite: 129 passed / 11 skipped.
1 parent 61d235f commit f2f33a8

3 files changed

Lines changed: 339 additions & 13 deletions

File tree

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""GDN Path C — TileLang DSL ``@T.prim_func`` lowered to Metal via tvm_ffi.
2+
3+
Mirrors the structure of ``cppmega_mlx/nn/_tilelang/mamba3_path_c.py`` (which
4+
ports Mamba3 MIMO fwd to TileLang): we declare the GDN recurrence as a
5+
per-lane scan in TileLang IR, run it through ``dispatch_lower(..., target='metal',
6+
return_msl=True)`` for MSL extraction, then compile with
7+
``tilelang.compile(..., target=_as_metal_target('metal'),
8+
execution_backend='tvm_ffi', out_idx=[...])`` into caller-owned MLX buffers.
9+
10+
This file is plugin-isolated: it imports ``dispatch_lower`` /
11+
``_msl_transform`` from the host ``cppmega_mlx`` package (read-only use of
12+
the existing TileLang→MSL infrastructure) but never modifies them.
13+
14+
When tilelang isn't importable in the current env (no torch, etc.), the
15+
status check returns ``available=False`` with a precise reason, and any
16+
direct call falls back to Path A's pure-MLX reference. Once tilelang lands
17+
in the env, the kernel will compile on first invocation, then be cached via
18+
``functools.lru_cache``.
19+
20+
Layout / lane mapping
21+
---------------------
22+
Following mamba3 fwd's per-lane recurrence, we use ``LANES = B * H * V``
23+
threads. Each lane owns one ``(b, h, v_idx)`` slice and walks the time
24+
dimension serially while reducing over K for the kv-state inner product and
25+
the q-state output projection. State is held in registers as ``K`` floats
26+
per lane (size of the K dimension).
27+
28+
Recurrence per step ``t``:
29+
decay = exp(g[b, t, h])
30+
h[i] *= decay # for i in K (alpha decay)
31+
kth_S = sum_i k[i] * h[i]
32+
v_eff = beta[b, t, h] * (v[v_idx] - kth_S)
33+
h[i] += k[i] * v_eff
34+
out = sum_i q[i] * h[i]
35+
y[b, t, h, v_idx] = out
36+
"""
37+
38+
from __future__ import annotations
39+
40+
from functools import lru_cache
41+
from typing import Any
42+
43+
import mlx.core as mx
44+
45+
46+
def _tilelang_importable() -> tuple[bool, str]:
47+
"""Probe whether the full tilelang stack is loadable.
48+
49+
Mirrors ``cppmega_mlx.nn._tilelang.mamba3_path_c._tilelang_available`` but
50+
inlined here so this module can be imported even when the host package's
51+
deeper helpers aren't reachable (e.g. partial install).
52+
"""
53+
try:
54+
import tilelang # noqa: F401
55+
import tilelang.language as _T # noqa: F401
56+
from tilelang.engine.lower import lower as _lower # noqa: F401
57+
except Exception as exc:
58+
return False, f"tilelang import failed: {exc.__class__.__name__}: {exc}"
59+
try:
60+
from cppmega_mlx.nn._tilelang._engine_dispatch import dispatch_lower # noqa: F401
61+
from cppmega_mlx.nn._tilelang import _msl_transform # noqa: F401
62+
except Exception as exc:
63+
return False, f"host TileLang→MSL infra not reachable: {exc}"
64+
return True, "tilelang + host TileLang→MSL infra reachable"
65+
66+
67+
def _threads_for(lanes: int) -> int:
68+
"""Pick a thread-group size that evenly tiles ``lanes`` (mirror mamba3)."""
69+
for tg in (256, 192, 128, 96, 64, 32):
70+
if lanes % tg == 0:
71+
return tg
72+
return 32
73+
74+
75+
@lru_cache(maxsize=64)
76+
def _fwd_kernel_for(
77+
BATCH: int,
78+
SEQ: int,
79+
HEADS: int,
80+
HEADDIM_K: int,
81+
HEADDIM_V: int,
82+
q_dtype: str = "float32",
83+
k_dtype: str = "float32",
84+
v_dtype: str = "float32",
85+
beta_dtype: str = "float32",
86+
g_dtype: str = "float32",
87+
h0_dtype: str = "float32",
88+
y_dtype: str = "float32",
89+
h_last_dtype: str = "float32",
90+
) -> tuple[Any, Any]:
91+
"""Build (and cache) the GDN fwd Path C kernel for this shape/dtype tuple.
92+
93+
Raises ``ImportError`` if tilelang isn't importable — callers should
94+
wrap in a try/except and fall back to Path A.
95+
"""
96+
import tilelang
97+
import tilelang.language as T
98+
from cppmega_mlx.nn._tilelang import _msl_transform
99+
from cppmega_mlx.nn._tilelang._engine_dispatch import dispatch_lower
100+
101+
LANES = BATCH * HEADS * HEADDIM_V
102+
THREADS = _threads_for(LANES)
103+
accum_dtype = "float32"
104+
105+
@T.prim_func
106+
def fwd(
107+
q: T.Tensor((BATCH, SEQ, HEADS, HEADDIM_K), q_dtype),
108+
k: T.Tensor((BATCH, SEQ, HEADS, HEADDIM_K), k_dtype),
109+
v: T.Tensor((BATCH, SEQ, HEADS, HEADDIM_V), v_dtype),
110+
beta: T.Tensor((BATCH, SEQ, HEADS), beta_dtype),
111+
g: T.Tensor((BATCH, SEQ, HEADS), g_dtype),
112+
h0: T.Tensor((BATCH, HEADS, HEADDIM_K, HEADDIM_V), h0_dtype),
113+
y: T.Tensor((BATCH, SEQ, HEADS, HEADDIM_V), y_dtype),
114+
h_last: T.Tensor((BATCH, HEADS, HEADDIM_K, HEADDIM_V), h_last_dtype),
115+
):
116+
with T.Kernel(T.ceildiv(LANES, THREADS), threads=THREADS) as bx:
117+
tid_in_block = T.get_thread_binding(0)
118+
global_lane = bx * THREADS + tid_in_block
119+
# Per-lane register state of size K (one column of the H matrix).
120+
h_state = T.alloc_local((HEADDIM_K,), accum_dtype)
121+
if global_lane < LANES:
122+
vj = global_lane % HEADDIM_V
123+
head = (global_lane // HEADDIM_V) % HEADS
124+
bb = global_lane // (HEADDIM_V * HEADS)
125+
# Init from h0[bb, head, :, vj].
126+
for i in T.serial(HEADDIM_K):
127+
h_state[i] = T.cast(h0[bb, head, i, vj], accum_dtype)
128+
for t in T.serial(SEQ):
129+
g_val = T.cast(g[bb, t, head], accum_dtype)
130+
beta_val = T.cast(beta[bb, t, head], accum_dtype)
131+
decay = T.exp(g_val)
132+
v_j = T.cast(v[bb, t, head, vj], accum_dtype)
133+
# Phase 1: alpha decay and KS reduction (interleaved).
134+
kth_S_j = T.alloc_var(T.float32, init=0.0)
135+
for i in T.serial(HEADDIM_K):
136+
h_state[i] = h_state[i] * decay
137+
kth_S_j += T.cast(k[bb, t, head, i], accum_dtype) * h_state[i]
138+
# Phase 2: delta correction.
139+
v_eff = beta_val * (v_j - kth_S_j)
140+
# Phase 3: rank-1 outer add + output projection along K.
141+
out = T.alloc_var(T.float32, init=0.0)
142+
for i in T.serial(HEADDIM_K):
143+
k_i = T.cast(k[bb, t, head, i], accum_dtype)
144+
q_i = T.cast(q[bb, t, head, i], accum_dtype)
145+
h_state[i] = h_state[i] + k_i * v_eff
146+
out += q_i * h_state[i]
147+
y[bb, t, head, vj] = T.cast(out, y_dtype)
148+
for i in T.serial(HEADDIM_K):
149+
h_last[bb, head, i, vj] = T.cast(h_state[i], h_last_dtype)
150+
151+
artifact = dispatch_lower(fwd, target="metal", return_msl=True)
152+
lowering = artifact # TileLangMSLLowering when MSL extraction succeeds
153+
kernel = tilelang.compile(
154+
fwd,
155+
target=_msl_transform._as_metal_target("metal"),
156+
execution_backend="tvm_ffi",
157+
out_idx=[6, 7], # y, h_last
158+
)
159+
return kernel, lowering
160+
161+
162+
def _path_c_runtime_status() -> tuple[bool, str]:
163+
"""Status visible to the dispatch layer."""
164+
return _tilelang_importable()
165+
166+
167+
def _gdn_fwd_path_c_call(
168+
q: mx.array,
169+
k: mx.array,
170+
v: mx.array,
171+
beta: mx.array,
172+
g: mx.array,
173+
*,
174+
scale: float | None = None,
175+
initial_state: mx.array | None = None,
176+
output_final_state: bool = False,
177+
):
178+
"""Path C entry — same signature as ``naive_recurrent_gated_delta_rule``.
179+
180+
On any failure (tilelang missing, compile error, runtime error) raises
181+
``RuntimeError`` so the caller can fall back to Path A.
182+
"""
183+
import math
184+
185+
B, T_, H, K_dim = q.shape
186+
V_dim = v.shape[-1]
187+
# FLA applies q *= 1/sqrt(K) inside; we pre-scale to match.
188+
fla_scale = scale if scale is not None else 1.0 / math.sqrt(K_dim)
189+
q_scaled = (q.astype(mx.float32) * fla_scale).astype(q.dtype)
190+
191+
h0 = initial_state
192+
if h0 is None:
193+
h0 = mx.zeros((B, H, K_dim, V_dim), dtype=mx.float32)
194+
195+
kernel, _lowering = _fwd_kernel_for(
196+
B, T_, H, K_dim, V_dim,
197+
q_dtype=str(q.dtype).rsplit(".", 1)[-1],
198+
k_dtype=str(k.dtype).rsplit(".", 1)[-1],
199+
v_dtype=str(v.dtype).rsplit(".", 1)[-1],
200+
beta_dtype=str(beta.dtype).rsplit(".", 1)[-1],
201+
g_dtype=str(g.dtype).rsplit(".", 1)[-1],
202+
)
203+
y, h_last = kernel(q_scaled, k, v, beta, g, h0)
204+
return y, (h_last if output_final_state else None)
205+
206+
207+
__all__ = [
208+
"_fwd_kernel_for",
209+
"_gdn_fwd_path_c_call",
210+
"_path_c_runtime_status",
211+
"_tilelang_importable",
212+
]

cppmega_v4/_tilelang/linear_attention_paths.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -97,29 +97,35 @@ def _path_b_call(*args, **kwargs):
9797

9898
def _path_c_status() -> PathStatus:
9999
try:
100-
importlib.import_module("tilelang")
100+
from cppmega_v4._tilelang.linear_attention_path_c import (
101+
_path_c_runtime_status,
102+
)
101103
except Exception as exc:
102104
return PathStatus(
103-
path="path_c",
104-
available=False,
105-
reason=f"tilelang not importable: {exc}",
105+
path="path_c", available=False,
106+
reason=f"path_c module not importable: {exc}",
106107
)
107-
# tilelang importable, but the GDN PrimFunc -> Metal lowering isn't wired
108-
# through cppmega_v4 yet — mirror mamba3_path_c.py structure when we land it.
108+
ok, reason = _path_c_runtime_status()
109109
return PathStatus(
110-
path="path_c",
111-
available=False,
110+
path="path_c", available=ok,
112111
reason=(
113-
"TileLang importable but GDN Path C wiring pending: copy skeleton "
114-
"from cppmega_mlx/nn/_tilelang/mamba3_path_c.py, lift "
115-
"tilelang/examples/gdn/example_chunk_delta_h.py etc. via "
116-
"tilelang.compile(target='metal', execution_backend='tvm_ffi')"
112+
f"GDN Path C: TileLang DSL @T.prim_func → tilelang.compile("
113+
f"target='metal', execution_backend='tvm_ffi'). {reason}"
117114
),
118115
)
119116

120117

121118
def _path_c_call(*args, **kwargs):
122-
return _path_a_call(*args, **kwargs) # fallback
119+
"""Try Path C; fall back to Path A on any error (compile, runtime, missing infra)."""
120+
if not _path_c_status().available:
121+
return _path_a_call(*args, **kwargs)
122+
try:
123+
from cppmega_v4._tilelang.linear_attention_path_c import (
124+
_gdn_fwd_path_c_call,
125+
)
126+
return _gdn_fwd_path_c_call(*args, **kwargs)
127+
except Exception:
128+
return _path_a_call(*args, **kwargs)
123129

124130

125131
# --- Path D (Triton frontend) ---------------------------------------------
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""GDN Path C (TileLang DSL → Metal via tvm_ffi) tests.
2+
3+
Path C is environment-conditional: it only runs the real kernel when
4+
``tilelang`` is importable AND the host ``cppmega_mlx`` TileLang→MSL
5+
infrastructure is reachable. In CI/dev envs without tilelang (typical on
6+
Apple Silicon without the TVM toolchain), the dispatch transparently falls
7+
back to Path A — so tests cover both modes:
8+
9+
1. Module imports cleanly without tilelang installed.
10+
2. Status reports a precise reason when unavailable.
11+
3. Dispatch with env forced to ``path_c`` still returns Path-A-equivalent
12+
output (the fallback path) — no NaN, correct shape.
13+
4. When tilelang IS importable, the @T.prim_func builds and produces
14+
parity output (skipped if tilelang missing).
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import mlx.core as mx
20+
import numpy as np
21+
import pytest
22+
23+
from cppmega_v4._tilelang.linear_attention_path_c import (
24+
_path_c_runtime_status,
25+
_tilelang_importable,
26+
)
27+
from cppmega_v4._tilelang.linear_attention_paths import (
28+
ENV_VAR as GDN_ENV,
29+
_path_c_status,
30+
gated_delta_recurrent_dispatch,
31+
)
32+
from cppmega_v4.nn._external.fla_naive_gated_delta_rule import (
33+
naive_recurrent_gated_delta_rule,
34+
)
35+
36+
37+
def test_path_c_module_imports():
38+
"""Module must be importable even when tilelang isn't installed."""
39+
from cppmega_v4._tilelang import linear_attention_path_c # noqa: F401
40+
41+
42+
def test_path_c_status_reason_is_precise():
43+
st = _path_c_status()
44+
# The reason string must mention TileLang and the lowering pipeline,
45+
# regardless of whether the backend is actually wired up.
46+
assert "TileLang" in st.reason or "tilelang" in st.reason
47+
assert "metal" in st.reason.lower()
48+
assert "tvm_ffi" in st.reason
49+
50+
51+
def test_path_c_runtime_status_matches_dispatch_status():
52+
ok, reason = _path_c_runtime_status()
53+
st = _path_c_status()
54+
assert st.available == ok
55+
# The runtime reason should be embedded in the dispatch reason.
56+
assert reason in st.reason
57+
58+
59+
def test_path_c_forced_via_env_returns_valid_output(monkeypatch):
60+
"""Forcing path_c must always produce a valid output (real or fallback)."""
61+
monkeypatch.setenv(GDN_ENV, "path_c")
62+
B, T, H, K, V = 1, 4, 2, 32, 32
63+
q = mx.random.normal((B, T, H, K))
64+
k = mx.random.normal((B, T, H, K))
65+
v = mx.random.normal((B, T, H, V))
66+
beta = mx.sigmoid(mx.random.normal((B, T, H)))
67+
g = -mx.abs(mx.random.normal((B, T, H)) * 0.1)
68+
o, _ = gated_delta_recurrent_dispatch(q, k, v, beta, g)
69+
assert o.shape == (B, T, H, V)
70+
assert not bool(mx.any(mx.isnan(o)).item())
71+
72+
73+
def test_path_c_fallback_matches_path_a_when_unavailable(monkeypatch):
74+
"""When the real kernel isn't available, dispatch must equal Path A bit-for-bit."""
75+
ok, _ = _tilelang_importable()
76+
if ok:
77+
pytest.skip("tilelang available — fallback path not exercised here")
78+
monkeypatch.setenv(GDN_ENV, "path_c")
79+
B, T, H, K, V = 1, 5, 2, 8, 8
80+
rng = np.random.default_rng(7)
81+
q = mx.array(rng.standard_normal((B, T, H, K)).astype(np.float32))
82+
k = mx.array(rng.standard_normal((B, T, H, K)).astype(np.float32))
83+
v = mx.array(rng.standard_normal((B, T, H, V)).astype(np.float32))
84+
beta = mx.array(rng.uniform(0.1, 0.9, (B, T, H)).astype(np.float32))
85+
g = mx.array(-rng.uniform(0.01, 0.5, (B, T, H)).astype(np.float32))
86+
o_disp, _ = gated_delta_recurrent_dispatch(q, k, v, beta, g)
87+
o_ref, _ = naive_recurrent_gated_delta_rule(q, k, v, beta, g)
88+
np.testing.assert_array_equal(np.array(o_disp), np.array(o_ref))
89+
90+
91+
@pytest.mark.skipif(
92+
not _tilelang_importable()[0],
93+
reason="tilelang not importable in this env",
94+
)
95+
def test_path_c_real_kernel_parity_with_path_a():
96+
"""Only runs when tilelang is reachable — actual prim_func parity check."""
97+
from cppmega_v4._tilelang.linear_attention_path_c import _gdn_fwd_path_c_call
98+
99+
B, T, H, K, V = 1, 4, 2, 32, 32
100+
rng = np.random.default_rng(11)
101+
q = mx.array(rng.standard_normal((B, T, H, K)).astype(np.float32))
102+
k = mx.array(rng.standard_normal((B, T, H, K)).astype(np.float32))
103+
v = mx.array(rng.standard_normal((B, T, H, V)).astype(np.float32))
104+
beta = mx.array(rng.uniform(0.1, 0.9, (B, T, H)).astype(np.float32))
105+
g = mx.array(-rng.uniform(0.01, 0.5, (B, T, H)).astype(np.float32))
106+
o_c, _ = _gdn_fwd_path_c_call(q, k, v, beta, g)
107+
o_a, _ = naive_recurrent_gated_delta_rule(q, k, v, beta, g)
108+
np.testing.assert_allclose(np.array(o_c), np.array(o_a), atol=1e-4, rtol=1e-3)

0 commit comments

Comments
 (0)