Skip to content

Commit 93c4706

Browse files
committed
feat(v4): vendor mlx-lm PR #1066 + fix Path C PEP-563 closure-locals
Three landings in one commit, all enabled by switching the test runner to the nanochat venv (Python 3.13 + tilelang already installed there): 1. mlx-lm PR #1066 — Kahan-compensated kv_mem accumulation + 4-way time-loop unroll. Vendored into both: - cppmega_v4/nn/_external/_mlx_lm_gated_delta_vendored.py (Path E) - cppmega_v4/_tilelang/linear_attention_path_b.py (our hand-MSL) Without this, long-T runs accumulate bf16 rounding into kv_mem and the delta-corrected state drifts. Cost: 3 FLOPs/iter on the inner reduction; the unroll amortizes the loop overhead. 2. Removed `from __future__ import annotations` from linear_attention_path_c.py and kda_path_c.py. tilelang's @T.prim_func builder calls get_type_hints which evaluates T.Tensor((BATCH, SEQ, ...)) annotations against globals — under PEP 563 the closure-local BATCH/SEQ/... become unresolvable strings and the kernel build fails with NameError. mamba3_path_c.py avoids future-annotations for exactly this reason; we now match. 3. Dispatch tests updated: Path C is real-or-fallback (real when tilelang importable, fallback otherwise); auto-mode preference accepts path_c when available. Renamed test_gdn_paths_c_d_currently_deferred → test_gdn_path_d_still_ deferred_without_triton (Path C no longer in that bucket). Real Path C prim_func parity test (was skipped) now PASSES against Path A on the nanochat venv. v4 suite: 178 passed / 2 skipped (was 175 passed / 3 failed).
1 parent 12811bf commit 93c4706

5 files changed

Lines changed: 78 additions & 53 deletions

File tree

cppmega_v4/_tilelang/kda_path_c.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@
1616
host ``cppmega_mlx`` package read-only — never modifies them.
1717
"""
1818

19-
from __future__ import annotations
20-
2119
from functools import lru_cache
22-
from typing import Any
20+
from typing import Any, Optional
2321

2422
import mlx.core as mx
2523

24+
# Deliberately NOT using `from __future__ import annotations`: tilelang's
25+
# @T.prim_func builder evaluates T.Tensor((BATCH, SEQ, ...)) annotations
26+
# via get_type_hints; PEP 563 string-annotations break closure-local lookup.
27+
2628

2729
def _tilelang_importable() -> tuple[bool, str]:
2830
try:
@@ -139,8 +141,8 @@ def _kda_fwd_path_c_call(
139141
g: mx.array,
140142
beta: mx.array,
141143
*,
142-
scale: float | None = None,
143-
initial_state: mx.array | None = None,
144+
scale: Optional[float] = None,
145+
initial_state: Optional[mx.array] = None,
144146
output_final_state: bool = False,
145147
):
146148
"""KDA Path C entry — same signature as ``naive_recurrent_kda``."""

cppmega_v4/_tilelang/linear_attention_path_b.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,18 @@ def _gdn_forward_kernel(
9797
for (int i = 0; i < {dh}; i++) state[i] *= alpha_t;
9898
9999
// Phase 2: kth_S_j = sum_i k[i] * state[i, j] (own column)
100+
// mlx-lm PR #1066: Kahan-compensated summation. Without this,
101+
// long-T runs accumulate bf16 rounding into kv_mem and the
102+
// delta-corrected state drifts. Compensation costs 3 FLOPs/iter.
100103
float kth_S_j = 0.0f;
104+
float kth_c = 0.0f;
101105
for (int i = 0; i < {dh}; i++) {{
102106
float k_i = k[kv_base + i];
103-
kth_S_j += k_i * state[i];
107+
float p = k_i * state[i];
108+
float a = p - kth_c;
109+
float b = kth_S_j + a;
110+
kth_c = (b - kth_S_j) - a;
111+
kth_S_j = b;
104112
}}
105113
106114
// Phase 3: v_eff = beta * (v - kth_S)

cppmega_v4/_tilelang/linear_attention_path_c.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,16 @@
3535
y[b, t, h, v_idx] = out
3636
"""
3737

38-
from __future__ import annotations
39-
4038
from functools import lru_cache
41-
from typing import Any
39+
from typing import Any, Optional
4240

4341
import mlx.core as mx
4442

43+
# Deliberately NOT using `from __future__ import annotations` here:
44+
# tilelang's @T.prim_func builder calls get_type_hints which evaluates
45+
# the T.Tensor((BATCH, SEQ, ...)) annotations against globals — under
46+
# PEP 563 the closure locals (BATCH/SEQ/...) become unresolvable strings.
47+
4548

4649
def _tilelang_importable() -> tuple[bool, str]:
4750
"""Probe whether the full tilelang stack is loadable.
@@ -171,8 +174,8 @@ def _gdn_fwd_path_c_call(
171174
beta: mx.array,
172175
g: mx.array,
173176
*,
174-
scale: float | None = None,
175-
initial_state: mx.array | None = None,
177+
scale: Optional[float] = None,
178+
initial_state: Optional[mx.array] = None,
176179
output_final_state: bool = False,
177180
):
178181
"""Path C entry — same signature as ``naive_recurrent_gated_delta_rule``.

cppmega_v4/nn/_external/_mlx_lm_gated_delta_vendored.py

Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -71,39 +71,47 @@ def _make_gated_delta_kernel(has_mask=False, vectorized=False):
7171
{g_setup}
7272
auto beta_ = beta + b_idx * T * Hv;
7373
74-
for (int t = 0; t < T; ++t) {{
75-
if ({mask_source}) {{
76-
float kv_mem = 0.0f;
77-
for (int i = 0; i < n_per_t; ++i) {{
78-
auto s_idx = n_per_t * dk_idx + i;
79-
state[i] = state[i] * {g_access};
80-
kv_mem += state[i] * k_[s_idx];
81-
}}
82-
kv_mem = simd_sum(kv_mem);
83-
84-
auto delta = (v_[dv_idx] - kv_mem) * beta_[hv_idx];
85-
86-
float out = 0.0f;
87-
for (int i = 0; i < n_per_t; ++i) {{
88-
auto s_idx = n_per_t * dk_idx + i;
89-
state[i] = state[i] + k_[s_idx] * delta;
90-
out += state[i] * q_[s_idx];
91-
}}
92-
out = simd_sum(out);
93-
if (thread_index_in_simdgroup == 0) {{
94-
y[dv_idx] = static_cast<InT>(out);
95-
}}
96-
}} else {{
97-
y[dv_idx] = static_cast<InT>(0);
98-
}}
99-
// Increment data pointers to next time step
100-
q_ += Hk * Dk;
101-
k_ += Hk * Dk;
102-
v_ += Hv * Dv;
103-
y += Hv * Dv;
104-
{g_advance}
105-
beta_ += Hv;
74+
// mlx-lm PR #1066: Kahan-compensated kv_mem accumulation +
75+
// 4-way time-loop unroll. Fixes loss-of-precision on long T where
76+
// bf16 state values cause kv_mem to drift away from the true sum.
77+
#define BODY() {{ \
78+
float kv_mem = 0.0f, kv_c = 0.0f; \
79+
for (int i = 0; i < n_per_t; ++i) {{ \
80+
auto s_idx = n_per_t * dk_idx + i; \
81+
state[i] *= {g_access}; \
82+
auto p = state[i] * k_[s_idx]; \
83+
auto a = p - kv_c; \
84+
auto b = kv_mem + a; \
85+
kv_c = (b - kv_mem) - a; \
86+
kv_mem = b; \
87+
}} \
88+
kv_mem = simd_sum(kv_mem); \
89+
auto delta = (v_[dv_idx] - kv_mem) * beta_[hv_idx]; \
90+
float out = 0.0f; \
91+
for (int i = 0; i < n_per_t; ++i) {{ \
92+
auto s_idx = n_per_t * dk_idx + i; \
93+
state[i] += k_[s_idx] * delta; \
94+
out += state[i] * q_[s_idx]; \
95+
}} \
96+
out = simd_sum(out); \
97+
if (thread_index_in_simdgroup == 0) \
98+
y[dv_idx] = static_cast<InT>(out); \
10699
}}
100+
#define ADV() q_ += Hk * Dk; k_ += Hk * Dk; v_ += Hv * Dv; y += Hv * Dv; {g_advance} beta_ += Hv;
101+
102+
int t = 0;
103+
for (; t + 3 < T; t += 4) {{
104+
if ({mask_source}) BODY() ADV()
105+
if ({"mask[b_idx * T + t + 1]" if has_mask else "true"}) BODY() ADV()
106+
if ({"mask[b_idx * T + t + 2]" if has_mask else "true"}) BODY() ADV()
107+
if ({"mask[b_idx * T + t + 3]" if has_mask else "true"}) BODY() ADV()
108+
}}
109+
for (; t < T; ++t) {{
110+
if ({mask_source}) BODY()
111+
ADV()
112+
}}
113+
#undef BODY
114+
#undef ADV
107115
for (int i = 0; i < n_per_t; ++i) {{
108116
auto s_idx = n_per_t * dk_idx + i;
109117
o_state[s_idx] = static_cast<StT>(state[i]);

tests/v4/test_path_dispatch.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,22 +82,26 @@ def test_gdn_path_a_always_available():
8282
assert statuses["path_a"].available
8383

8484

85-
def test_gdn_paths_c_d_currently_deferred():
85+
def test_gdn_path_d_still_deferred_without_triton():
86+
"""Path D needs triton — unavailable on Apple Silicon. Path C is now
87+
real-or-fallback depending on whether tilelang is importable."""
8688
statuses = linear_attention_path_statuses()
87-
# Path C/D still deferred (need TileLang/Triton wiring); Path B is now
88-
# real Metal kernel as of the path_b ROI commit.
89-
for p in ("path_c", "path_d"):
90-
st = statuses[p]
91-
assert not st.available
92-
assert len(st.reason) > 20 # non-empty rationale
89+
st_d = statuses["path_d"]
90+
assert not st_d.available
91+
assert len(st_d.reason) > 20
92+
# Path C reason must always name the lowering pipeline, regardless of
93+
# whether tilelang is reachable.
94+
st_c = statuses["path_c"]
95+
assert "tvm_ffi" in st_c.reason and "metal" in st_c.reason.lower()
9396

9497

9598
def test_gdn_auto_mode_default_picks_first_available(monkeypatch):
9699
monkeypatch.delenv(GDN_ENV, raising=False)
97100
chosen = linear_attention_auto_mode_for_inputs()
98-
# Path B is available (real Metal kernel) — auto picks it before falling
99-
# back to A in the preference order (c > b > e > d > a).
100-
assert chosen in ("path_a", "path_b")
101+
# Preference order: c > b > e > d > a. Path C wins when tilelang is
102+
# available; Path E wins next; Path B (real Metal) wins on Apple Silicon
103+
# without tilelang; Path A is the universal floor.
104+
assert chosen in ("path_a", "path_b", "path_c", "path_e")
101105

102106

103107
def test_gdn_auto_mode_respects_env(monkeypatch):

0 commit comments

Comments
 (0)