Skip to content

Commit 8875b99

Browse files
committed
feat(v4): KDA Path E via the SAME mlx-lm gated_delta kernel as GDN
Earlier I claimed KDA has no Path E because the upstream mlx-lm gated_delta_update only takes a per-head scalar gate. That was wrong — mlx_lm/models/gated_delta.py:_make_gated_delta_kernel has a `vectorized` flag, and gated_delta_kernel auto-selects the vectorised-gate variant when g.ndim == 4. mlx-lm itself drives this branch from kimi_linear.py::KimiDeltaAttention, which is the KDA-style attention. This commit adds the matching adapter + dispatch wiring: - cppmega_v4/nn/_external/mlx_lm_kda_update.py: thin adapter that maps our FLA-style (q, k, v, g_per_K, beta) onto the upstream (q_scaled, k, v, g_decay=exp(g), beta, state_transposed) call. State layout flips between FLA [B,HV,K,V] and upstream [B,Hv,Dv,Dk]. Smaller dims (Dk%32!=0 or Dv%4!=0) drop to the pure-ops reference. - cppmega_v4/_tilelang/kda_paths.py: _path_e_status / _path_e_call / register in kda_path_statuses(); preference tuple updated to ("path_c", "path_b", "path_e", "path_d", "path_a"); the previous 'KDA has no Path E' ValueError on env-forced path_e is gone. - cppmega_v4/_tilelang/benchmark_matrix.py: KDA_PATHS includes path_e. - tests: - new KDA Path E tests: status, kernel-path parity, ops-fallback parity, env-forced dispatch - test_kda_statuses_keys (both copies) updated to expect the 5-key set - test_kda_auto_mode_rejects_path_e replaced with test_kda_auto_mode_accepts_path_e - test_run_matrix_kda_covers_4_paths_no_path_e renamed + assertion bumped to 5 cells Parity smoke: KDA path_e vs path_a maxdiff ~2e-6 (B=1,T=4,H=2,HV=4,K=V=32). Suite: 371 passed / 0 skipped (was 367 passed / 0 skipped).
1 parent 86e1b5e commit 8875b99

6 files changed

Lines changed: 253 additions & 17 deletions

File tree

cppmega_v4/_tilelang/benchmark_matrix.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
}
5252

5353
GDN_PATHS: tuple[Path_, ...] = ("path_a", "path_b", "path_c", "path_d", "path_e")
54-
KDA_PATHS: tuple[Path_, ...] = ("path_a", "path_b", "path_c", "path_d")
54+
KDA_PATHS: tuple[Path_, ...] = ("path_a", "path_b", "path_c", "path_d", "path_e")
5555

5656

5757
@dataclass

cppmega_v4/_tilelang/kda_paths.py

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
"""KDA multi-path dispatch (Paths B/C/D + auto-mode).
1+
"""KDA multi-path dispatch (Paths B/C/D/E + auto-mode).
22
3-
Same shape as ``linear_attention_paths.py`` but for the KDA backend. KDA
4-
has no mlx-lm equivalent op (no Path E).
3+
Same shape as ``linear_attention_paths.py`` but for the KDA backend. Path E
4+
reuses the SAME mlx-lm gated_delta Metal kernel as GDN — the kernel
5+
auto-selects the vectorised-gate variant when ``g.ndim == 4``
6+
(``mlx_lm/models/gated_delta.py::gated_delta_kernel``), which is exactly
7+
KDA's per-K log-decay shape. Mirrors how
8+
``mlx_lm/models/kimi_linear.py::KimiDeltaAttention`` drives the same
9+
upstream kernel from KDA-shaped inputs.
510
611
Backend status (May 2026):
712
- Path A: pure-MLX naive recurrent KDA (golden) — always available.
@@ -16,6 +21,10 @@
1621
- Path D: ``poc.triton_frontend.from_triton_kernel`` over FLA KDA
1722
chunk kernels. cppmega owns the runtime adapter for FLA's forward
1823
multi-kernel launch, including packed varlen metadata.
24+
- Path E: vendored mlx-lm gated_delta vectorised-gate Metal kernel
25+
(the same kernel that powers GDN Path E; KDA just hits the
26+
``g.ndim == 4`` branch). Requires Dk % 32 == 0 and Dv % 4 == 0;
27+
smaller dims drop to the pure-ops reference path.
1928
2029
Env override: ``CPPMEGA_V4_KERNEL_PATH__KDA``.
2130
"""
@@ -137,6 +146,43 @@ def _path_d_call(*args, **kwargs):
137146
return _path_a_call(*args, **kwargs)
138147

139148

149+
# ----- Path E (vendored mlx-lm gated_delta vectorised-gate kernel) -----
150+
151+
152+
def _path_e_status() -> PathStatus:
153+
try:
154+
importlib.import_module(
155+
"cppmega_v4.nn._external.mlx_lm_kda_update"
156+
)
157+
except Exception as exc:
158+
return PathStatus(
159+
path="path_e", available=False,
160+
reason=(
161+
"mlx-lm gated_delta vectorised-gate adapter not importable: "
162+
f"{exc}"
163+
),
164+
)
165+
return PathStatus(
166+
path="path_e", available=True,
167+
reason=(
168+
"vendored mlx-lm gated_delta vectorised-gate Metal kernel "
169+
"(Dk%32==0 & Dv%4==0; smaller dims fall back to the pure-ops "
170+
"reference). The same kernel as GDN Path E — KDA hits the "
171+
"g.ndim==4 branch in gated_delta_kernel."
172+
),
173+
)
174+
175+
176+
def _path_e_call(*args, **kwargs):
177+
if not _path_e_status().available:
178+
return _path_a_call(*args, **kwargs)
179+
try:
180+
from cppmega_v4.nn._external.mlx_lm_kda_update import kda_update
181+
return kda_update(*args, **kwargs)
182+
except Exception:
183+
return _path_a_call(*args, **kwargs)
184+
185+
140186
# ----- Public dispatch -----
141187

142188

@@ -146,20 +192,17 @@ def kda_path_statuses() -> dict[PathName, PathStatus]:
146192
"path_b": _path_b_status(),
147193
"path_c": _path_c_status(),
148194
"path_d": _path_d_status(),
195+
"path_e": _path_e_status(),
149196
}
150197

151198

152199
def kda_auto_mode_for_inputs(*, env_var: str = ENV_VAR) -> PathName:
153200
forced = env_override(env_var)
154201
if forced is not None:
155-
if forced == "path_e":
156-
raise ValueError(
157-
"KDA has no Path E; use path_a, path_b, path_c, path_d, or auto"
158-
)
159202
return forced # type: ignore[return-value]
160203
return auto_pick(
161204
kda_path_statuses(),
162-
preference=("path_c", "path_b", "path_d", "path_a"),
205+
preference=("path_c", "path_b", "path_e", "path_d", "path_a"),
163206
)
164207

165208

@@ -171,6 +214,7 @@ def kda_recurrent_dispatch(*args, **kwargs):
171214
"path_b": _path_b_call,
172215
"path_c": _path_c_call,
173216
"path_d": _path_d_call,
217+
"path_e": _path_e_call,
174218
}[path]
175219
return fn(*args, **kwargs)
176220

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""KDA Path E adapter — wraps mlx-lm's gated_delta kernel (vectorised gate).
2+
3+
The same upstream Metal kernel (``mlx_lm/models/gated_delta.py``,
4+
``_make_gated_delta_kernel(vectorized=True)``) that powers GDN Path E also
5+
handles the per-K vectorised gate used by KDA-style attention — see
6+
``mlx_lm/models/kimi_linear.py::KimiDeltaAttention`` which calls
7+
``gated_delta_update`` with ``a_logits`` shaped ``[B, T, num_heads, head_dim]``.
8+
9+
The kernel selection happens automatically inside ``gated_delta_kernel``:
10+
when ``g.ndim == 4`` it picks ``_gated_delta_kernel_vec``; when ``g.ndim == 3``
11+
it picks the scalar variant used by GDN. Group expansion (Hv > Hk) is also
12+
handled inside the kernel (``hk_idx = hv_idx / (Hv / Hk)``).
13+
14+
Our naive_recurrent_kda signature (FLA convention):
15+
naive_recurrent_kda(q, k, v, g, beta, scale=None,
16+
initial_state=None, output_final_state=False)
17+
-> (o, S)
18+
where:
19+
- q, k: [B, T, H, K] (un-repeated; HV/H expansion is kernel-side)
20+
- v: [B, T, HV, V]
21+
- g: [B, T, HV, K] (per-K log-decay; state is multiplied by exp(g))
22+
- beta: [B, T, HV]
23+
- S: [B, HV, K, V] (FLA layout — K rows, V cols)
24+
FLA pre-scales q by 1/sqrt(K).
25+
26+
Upstream gated_delta_kernel signature:
27+
gated_delta_kernel(q, k, v, g, beta, state, mask=None)
28+
- q, k: [B, T, Hk, Dk]
29+
- v: [B, T, Hv, Dv]
30+
- g: [B, T, Hv, Dk] (vectorized) OR [B, T, Hv] (scalar)
31+
- beta: [B, T, Hv] (kernel applies it as-is, no sigmoid)
32+
- state: [B, Hv, Dv, Dk] (upstream layout — V rows, K cols; transposed vs FLA)
33+
Upstream does NOT pre-scale q. Decay is plain exp(g) where g is the value
34+
we pass in directly (kernel does ``state[i] = state[i] * g_[s_idx]``),
35+
so we pass ``g_decay = exp(our_g)`` directly here.
36+
37+
Constraints:
38+
- Upstream Metal kernel requires Dk % 32 == 0 and Dv % 4 == 0; smaller
39+
dims fall back to the pure-ops reference path.
40+
"""
41+
42+
from __future__ import annotations
43+
44+
import math
45+
46+
import mlx.core as mx
47+
48+
from cppmega_v4.nn._external._mlx_lm_gated_delta_vendored import (
49+
gated_delta_kernel as _upstream_kernel,
50+
gated_delta_ops as _upstream_ops,
51+
)
52+
53+
54+
def kda_update(
55+
q: mx.array,
56+
k: mx.array,
57+
v: mx.array,
58+
g: mx.array,
59+
beta: mx.array,
60+
*,
61+
scale: float | None = None,
62+
initial_state: mx.array | None = None,
63+
output_final_state: bool = False,
64+
):
65+
"""Path E entry — same signature as ``naive_recurrent_kda``.
66+
67+
Args:
68+
q, k: [B, T, H, K] (FLA convention; HV/H expansion is kernel-side)
69+
v: [B, T, HV, V]
70+
g: [B, T, HV, K] per-K log-decay (state *= exp(g) per step)
71+
beta: [B, T, HV]
72+
scale: optional — defaults to 1/sqrt(K)
73+
initial_state: optional [B, HV, K, V]
74+
output_final_state: if True, return (o, S_final[B, HV, K, V])
75+
76+
Returns:
77+
(o[B, T, HV, V], S_final or None)
78+
"""
79+
if q.ndim != 4 or k.shape != q.shape:
80+
raise ValueError(
81+
f"q/k must be [B, T, H, K]; got q={q.shape}, k={k.shape}"
82+
)
83+
if v.ndim != 4 or v.shape[:2] != q.shape[:2]:
84+
raise ValueError(f"v must be [B, T, HV, V]; got v={v.shape}")
85+
if g.shape != (*v.shape[:3], k.shape[-1]):
86+
raise ValueError(
87+
f"g must be [B, T, HV, K]; got g={g.shape}, expected {(*v.shape[:3], k.shape[-1])}"
88+
)
89+
if beta.shape != v.shape[:3]:
90+
raise ValueError(f"beta must be [B, T, HV]; got beta={beta.shape}")
91+
92+
b_size, t_size, h_size, kdim = q.shape
93+
hv_size, vdim = v.shape[2], v.shape[-1]
94+
if hv_size % h_size != 0:
95+
raise ValueError(f"HV ({hv_size}) must be divisible by H ({h_size})")
96+
97+
# FLA pre-scales q by 1/sqrt(K); upstream does not.
98+
fla_scale = scale if scale is not None else 1.0 / math.sqrt(kdim)
99+
q_scaled = (q.astype(mx.float32) * fla_scale).astype(q.dtype)
100+
101+
# Upstream kernel multiplies state by `g` as-is each step. FLA convention:
102+
# state is multiplied by `exp(g)`. Pre-exponentiate.
103+
g_decay = mx.exp(g.astype(mx.float32))
104+
105+
# Upstream state layout: [B, Hv, Dv, Dk]. FLA: [B, HV, K, V]. Transpose
106+
# last two axes when handing off.
107+
if initial_state is None:
108+
state = mx.zeros((b_size, hv_size, vdim, kdim), dtype=mx.float32)
109+
else:
110+
state = mx.transpose(initial_state.astype(mx.float32), (0, 1, 3, 2))
111+
112+
beta_f = beta.astype(mx.float32)
113+
114+
# Upstream Metal kernel needs Dk % 32 == 0 and Dv % 4 == 0; otherwise
115+
# fall through to the pure-ops reference (which also handles vector-gate).
116+
use_kernel = (kdim % 32 == 0) and (vdim % 4 == 0) and mx.metal.is_available()
117+
118+
if use_kernel:
119+
y, new_state = _upstream_kernel(
120+
q_scaled, k, v, g_decay, beta_f, state, mask=None,
121+
)
122+
else:
123+
y, new_state = _upstream_ops(
124+
q_scaled, k, v, g_decay, beta_f, state, mask=None,
125+
)
126+
127+
final = None
128+
if output_final_state:
129+
# Convert back to FLA layout [B, HV, K, V].
130+
final = mx.transpose(new_state, (0, 1, 3, 2))
131+
return y, final
132+
133+
134+
__all__ = ["kda_update"]

tests/v4/test_benchmark_matrix.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,13 @@ def test_run_matrix_gdn_covers_all_5_paths():
125125
assert receipt.promotion.env_var.endswith("LINEAR_ATTENTION")
126126

127127

128-
def test_run_matrix_kda_covers_4_paths_no_path_e():
128+
def test_run_matrix_kda_covers_all_5_paths():
129129
shape = CellShape(
130130
block="kda", path="path_a", batch=1, seq_len=4,
131131
num_heads=2, head_dim_k=4, head_dim_v=4, num_v_heads=2,
132132
)
133133
receipt = run_matrix(shape, warmup=1, iters=2)
134-
assert len(receipt.cells) == 4
134+
assert len(receipt.cells) == 5
135135
paths_seen = {c.path for c in receipt.cells}
136136
assert paths_seen == set(KDA_PATHS)
137137
assert receipt.promotion.env_var.endswith("KDA")

tests/v4/test_kda_paths.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,5 +326,63 @@ def test_kda_path_d_try_lower_returns_seam_message():
326326
# ----- Dispatch sanity -----
327327

328328

329+
# ----- Path E (vendored mlx-lm gated_delta vec-gate kernel) -----
330+
331+
332+
def test_kda_path_e_status_available():
333+
from cppmega_v4._tilelang.kda_paths import _path_e_status
334+
st = _path_e_status()
335+
assert st.available, st.reason
336+
assert "gated_delta" in st.reason
337+
338+
339+
def test_kda_path_e_parity_with_path_a_kernel_path():
340+
"""Dk%32==0 + Dv%4==0 hits the Metal kernel — must match Path A."""
341+
from cppmega_v4.nn._external.mlx_lm_kda_update import kda_update
342+
B, T, H, HV, K, V = 1, 4, 2, 4, 32, 32
343+
rng = np.random.default_rng(123)
344+
q = mx.array(rng.standard_normal((B, T, H, K)).astype(np.float32))
345+
k = mx.array(rng.standard_normal((B, T, H, K)).astype(np.float32))
346+
v = mx.array(rng.standard_normal((B, T, HV, V)).astype(np.float32))
347+
g = mx.array(-rng.uniform(0.01, 0.2, (B, T, HV, K)).astype(np.float32))
348+
beta = mx.array(rng.uniform(0.1, 0.9, (B, T, HV)).astype(np.float32))
349+
o_e, sf_e = kda_update(q, k, v, g, beta, output_final_state=True)
350+
o_a, sf_a = naive_recurrent_kda(q, k, v, g, beta, output_final_state=True)
351+
mx.eval(o_e, o_a, sf_e, sf_a)
352+
np.testing.assert_allclose(np.array(o_e), np.array(o_a), atol=1e-4, rtol=1e-4)
353+
np.testing.assert_allclose(np.array(sf_e), np.array(sf_a), atol=1e-4, rtol=1e-4)
354+
355+
356+
def test_kda_path_e_parity_ops_fallback():
357+
"""Small dims (Dk%32!=0) fall through to the pure-ops reference."""
358+
from cppmega_v4.nn._external.mlx_lm_kda_update import kda_update
359+
B, T, H, HV, K, V = 1, 3, 2, 4, 8, 8
360+
rng = np.random.default_rng(131)
361+
q = mx.array(rng.standard_normal((B, T, H, K)).astype(np.float32))
362+
k = mx.array(rng.standard_normal((B, T, H, K)).astype(np.float32))
363+
v = mx.array(rng.standard_normal((B, T, HV, V)).astype(np.float32))
364+
g = mx.array(-rng.uniform(0.01, 0.2, (B, T, HV, K)).astype(np.float32))
365+
beta = mx.array(rng.uniform(0.1, 0.9, (B, T, HV)).astype(np.float32))
366+
o_e, _ = kda_update(q, k, v, g, beta)
367+
o_a, _ = naive_recurrent_kda(q, k, v, g, beta)
368+
np.testing.assert_allclose(np.array(o_e), np.array(o_a), atol=1e-4, rtol=1e-4)
369+
370+
371+
def test_kda_path_e_forced_via_env(monkeypatch):
372+
monkeypatch.setenv(KDA_ENV, "path_e")
373+
B, T, H, HV, K, V = 1, 4, 2, 4, 32, 32
374+
rng = np.random.default_rng(141)
375+
q = mx.array(rng.standard_normal((B, T, H, K)).astype(np.float32))
376+
k = mx.array(rng.standard_normal((B, T, H, K)).astype(np.float32))
377+
v = mx.array(rng.standard_normal((B, T, HV, V)).astype(np.float32))
378+
g = mx.array(-rng.uniform(0.01, 0.2, (B, T, HV, K)).astype(np.float32))
379+
beta = mx.array(rng.uniform(0.1, 0.9, (B, T, HV)).astype(np.float32))
380+
o, _ = kda_recurrent_dispatch(q, k, v, g, beta)
381+
assert o.shape == (B, T, HV, V)
382+
assert not bool(mx.any(mx.isnan(o)).item())
383+
384+
329385
def test_kda_statuses_keys_unchanged():
330-
assert set(kda_path_statuses().keys()) == {"path_a", "path_b", "path_c", "path_d"}
386+
assert set(kda_path_statuses().keys()) == {
387+
"path_a", "path_b", "path_c", "path_d", "path_e",
388+
}

tests/v4/test_path_dispatch.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,19 +145,19 @@ def test_gdn_dispatch_each_path_runs(monkeypatch, path):
145145

146146

147147
def test_kda_statuses_keys():
148-
# KDA has no Path E.
148+
# KDA now has Path E (same upstream Metal kernel as GDN; KDA hits the
149+
# vectorised-gate branch via g.ndim==4).
149150
statuses = kda_path_statuses()
150-
assert set(statuses.keys()) == {"path_a", "path_b", "path_c", "path_d"}
151+
assert set(statuses.keys()) == {"path_a", "path_b", "path_c", "path_d", "path_e"}
151152

152153

153154
def test_kda_path_a_always_available():
154155
assert kda_path_statuses()["path_a"].available
155156

156157

157-
def test_kda_auto_mode_rejects_path_e(monkeypatch):
158+
def test_kda_auto_mode_accepts_path_e(monkeypatch):
158159
monkeypatch.setenv(KDA_ENV, "path_e")
159-
with pytest.raises(ValueError, match="no Path E"):
160-
kda_auto_mode_for_inputs()
160+
assert kda_auto_mode_for_inputs() == "path_e"
161161

162162

163163
def test_kda_dispatch_returns_same_as_path_a(monkeypatch):

0 commit comments

Comments
 (0)