|
| 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 | +] |
0 commit comments