Skip to content

Commit 1f8675b

Browse files
committed
feat(v4-path-e): remove Dk%32 forward shape limit via in-MSL remainder-mask
The fast gated_delta Metal kernel silently truncated the Dk tail (n_per_t=Dk/32 floor dropped the trailing Dk%32 keys -> wrong answer for non-mult-32 Dk). Fixed in the actual MSL: ceil tiling n_per_t=(Dk+31)/32 + per-i s_idx<Dk guards on every k/q/state load (additive identity 0 past Dk), and the hard part -- the VECTORIZED KDA gate tail-masked to decay exp(0)=1 (never reads g_[s_idx] OOB) so the pre-slice simd_sum(kv_mem) is not poisoned. Same mask in the VJP fwd-save + both backward kernels (incl. guarded [4*Dk] shared-tile writes). Forward now runs for ANY Dk. Eligibility: shape_uses_fast_kernel True for all shapes (forward); new shape_uses_fast_kernel_backward keeps Dv%4 for the VJP cooperative reduction. Training still fail-closes to the Python-ops VJP for Dv%4!=0 (deferred). Gate-sign behavior UNCHANGED (inherent GDN model-math: the kernel has no clamp; KDA feeds exp(g)>1 into the same kernel and amplifies; the (0,1] bound is the GDN parameterization a=softplus_inverse(-g)); only the reason string reworded. Provenance: the two kernel files reclassified verbatim->derived with upstream_base_sha256 + delta note. Metal parity vs gated_delta_ops (fp32): scalar fwd 65/65 rel<1e-6 (incl Dk 40/96/100/192); KDA vectorized-gate Dk%32!=0 rel ~1e-7..1e-6; asymmetric, group-expansion, state round-trip bit-exact; VJP odd-Dk (Dv%4==0) rel ~1e-6. 133 tests pass (65 new odd-shape); 6 unrelated pre-existing; v3 untouched.
1 parent fdb9362 commit 1f8675b

8 files changed

Lines changed: 350 additions & 90 deletions

cppmega_v4/_tilelang/linear_attention_paths.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,10 @@ def _path_e_status() -> PathStatus:
219219
return PathStatus(
220220
path="path_e", available=True,
221221
reason=(
222-
"vendored mlx-lm PR #1217 gated_delta_update (fast Metal kernel "
223-
"for Dk%32==0 & Dv%4==0 and gate g<=0; fails closed otherwise "
224-
"so the dispatcher falls back to Path B/A)"
222+
"vendored mlx-lm PR #1217 gated_delta_update (fast Metal kernel; "
223+
"forward runs for ANY Dk via the in-MSL remainder-mask and any "
224+
"Dv; gate must be g<=0 for GDN, otherwise fails closed so the "
225+
"dispatcher falls back to Path B/A)"
225226
),
226227
)
227228
except Exception:

cppmega_v4/nn/_external/VENDORED_MANIFEST.json

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@
1919
"pr": 1217,
2020
"pr_url": "https://github.com/ml-explore/mlx-lm/pull/1217",
2121
"upstream_path": "mlx_lm/models/gated_delta.py",
22-
"kind": "verbatim",
22+
"kind": "derived",
2323
"used_by": "GDN/KDA Path E (gated_delta_update, gated_delta_kernel, gated_delta_ops, compute_g)",
24-
"sha256": "18b3a77250b246d46e63d8fa9e8208936ea09bd46b97b036c2ec34edfc0a176b",
25-
"notes": "Snapshot includes the PR #1066 Kahan-compensated kv_mem accumulation + 4-way time-loop unroll; this is NOT identical to an older mlx-lm checkout that predates PR #1066."
24+
"upstream_base_sha256": "18b3a77250b246d46e63d8fa9e8208936ea09bd46b97b036c2ec34edfc0a176b",
25+
"sha256": "d2f3c76ca2870c98dfe95390287f01a4ee75d048227f0784076826938514ea08",
26+
"notes": "Was 'verbatim' (PR #1217 snapshot, incl. PR #1066 Kahan kv_mem + 4-way unroll). Reclassified 'derived': cppmega added an in-MSL Dk remainder-mask (ceil tiling n_per_t=(Dk+31)/32 + per-i s_idx<Dk guards; vectorized gate uses decay=1.0 on the masked tail) so the fast Metal forward kernel is correct for ANY Dk, not just Dk%32==0 — upstream silently truncated the trailing Dk%32 keys. upstream_base_sha256 records the pre-delta PR #1217 snapshot; the only delta is this remainder-mask."
2627
},
2728
{
2829
"file": "_mlx_lm_fp8_dequant_vendored.py",
@@ -41,8 +42,9 @@
4142
"upstream_path": "mlx_lm/models/gated_delta.py (kernel reused; VJP is cppmega-authored)",
4243
"kind": "derived",
4344
"used_by": "GDN Path E training backward (fused Metal VJP)",
44-
"sha256": "dfe959d0ed50d60d75d93532a80b0b6f769aad74ce39bc00a0770128c9903f4e",
45-
"notes": "cppmega-authored fused Metal VJP built on the vendored PR #1217 gated_delta_kernel; upstream has no registered VJP."
45+
"upstream_base_sha256": "dfe959d0ed50d60d75d93532a80b0b6f769aad74ce39bc00a0770128c9903f4e",
46+
"sha256": "e4448b96fef0830c02c88bae918cd02e7c053892bde7f9021230d18384ea9386",
47+
"notes": "cppmega-authored fused Metal VJP built on the vendored PR #1217 gated_delta_kernel; upstream has no registered VJP. DELTA over upstream_base_sha256: same in-MSL Dk remainder-mask as the forward (ceil tiling + per-i s_idx<Dk guards in fwd-save and both bwd kernels; vectorized gate tail decay=1.0) so the VJP handles any Dk. Still requires Dv%4==0 (four-SIMD-group cooperative reduction over a fixed [4*Dk] tile) — ragged-Dv backward deferred."
4648
},
4749
{
4850
"file": "_mlx_lm_gated_delta_vjp_vendored.py",

cppmega_v4/nn/_external/_mlx_lm_gated_delta_vendored.py

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
1-
# Vendored verbatim from ml-explore/mlx-lm PR #1217
2-
# (mlx_lm/models/gated_delta.py)
1+
# Derived from ml-explore/mlx-lm PR #1217 (mlx_lm/models/gated_delta.py).
32
# Upstream license: MIT, Copyright © 2023 Apple Inc.
43
#
54
# Public surface used by cppmega_v4 Path E:
65
# - gated_delta_update(...) -> (y, state)
76
# - gated_delta_kernel(...) — low-level Metal kernel entry
87
# - gated_delta_ops(...) — pure-MLX reference for prefill
98
# - compute_g(A_log, a, dt_bias) — gate transform
10-
# No edits — pure copy. cppmega_v4/nn/path_e_adapter.py wraps our (g, beta)
11-
# API to this module's (a, b, A_log, dt_bias) API.
9+
# cppmega_v4/nn/path_e_adapter.py wraps our (g, beta) API to this module's
10+
# (a, b, A_log, dt_bias) API.
11+
#
12+
# CPPMEGA DELTA over the PR #1217 snapshot (see VENDORED_MANIFEST.json):
13+
# In-MSL Dk remainder-mask so the fast Metal kernel is CORRECT for ANY Dk,
14+
# not just Dk % 32 == 0. Upstream uses `n_per_t = Dk / 32` (integer floor),
15+
# so the trailing Dk % 32 keys owned past lane 31's slice were NEVER loaded
16+
# -> silent truncation / wrong answer for non-multiple-of-32 Dk. We switch
17+
# to a ceil tiling `n_per_t = (Dk + 31) / 32` and guard every per-`i` load
18+
# with `s_idx < Dk`, substituting additive/multiplicative identities for the
19+
# out-of-range tail (0 for k/q/state contributions; the VECTORIZED gate uses
20+
# decay = exp(0) = 1, i.e. g_access expands to 0.0 on the tail so that the
21+
# pre-reduction simd_sum is never poisoned). The scalar GDN gate is per-(b,t,
22+
# hv) broadcast and so unaffected. Everything else is the verbatim PR #1217
23+
# snapshot (incl. the PR #1066 Kahan-compensated kv_mem + 4-way unroll).
1224

1325
from functools import partial
1426
from typing import Optional, Tuple
@@ -27,11 +39,17 @@ def _make_gated_delta_kernel(has_mask=False, vectorized=False):
2739
return None
2840
mask_source = "mask[b_idx * T + t]" if has_mask else "true"
2941

30-
# Configure g indexing based on whether gating is vectorized
42+
# Configure g indexing based on whether gating is vectorized.
43+
# Vectorized g is [B,T,Hv,Dk] and is read per-key (s_idx). On the Dk
44+
# remainder tail (s_idx >= Dk) the load would be OOB, so we mask it and
45+
# substitute decay = 1.0 (== exp(0)) for that lane's tail entry. Using 1.0
46+
# leaves state[i] unchanged; since the tail state[i] is itself 0 (masked
47+
# load) and the tail k_/q_ contributions are 0, the tail never poisons the
48+
# pre-reduction simd_sum(kv_mem)/simd_sum(out).
3149
if vectorized:
3250
g_comment = "// g: [B, T, Hv, Dk]"
3351
g_setup = "auto g_ = g + (b_idx * T * Hv + hv_idx) * Dk;"
34-
g_access = "g_[s_idx]"
52+
g_access = "(s_idx < Dk ? static_cast<float>(g_[s_idx]) : 1.0f)"
3553
g_advance = "g_ += Hv * Dk;"
3654
else:
3755
g_comment = "// g: [B, T, Hv]"
@@ -44,7 +62,9 @@ def _make_gated_delta_kernel(has_mask=False, vectorized=False):
4462
auto b_idx = n / Hv;
4563
auto hv_idx = n % Hv;
4664
auto hk_idx = hv_idx / (Hv / Hk);
47-
constexpr int n_per_t = Dk / 32;
65+
// cppmega delta: ceil tiling so the trailing Dk % 32 keys are owned by
66+
// a lane and loaded (upstream used floor `Dk / 32`, dropping the tail).
67+
constexpr int n_per_t = (Dk + 31) / 32;
4868
4969
// q, k: [B, T, Hk, Dk]
5070
auto q_ = q + b_idx * T * Hk * Dk + hk_idx * Dk;
@@ -64,7 +84,9 @@ def _make_gated_delta_kernel(has_mask=False, vectorized=False):
6484
float state[n_per_t];
6585
for (int i = 0; i < n_per_t; ++i) {{
6686
auto s_idx = n_per_t * dk_idx + i;
67-
state[i] = static_cast<float>(i_state[s_idx]);
87+
// Dk remainder-mask: tail lanes hold 0 (additive identity) so they
88+
// contribute nothing to any simd_sum reduction.
89+
state[i] = (s_idx < Dk) ? static_cast<float>(i_state[s_idx]) : 0.0f;
6890
}}
6991
7092
{g_comment}
@@ -78,6 +100,7 @@ def _make_gated_delta_kernel(has_mask=False, vectorized=False):
78100
float kv_mem = 0.0f, kv_c = 0.0f; \
79101
for (int i = 0; i < n_per_t; ++i) {{ \
80102
auto s_idx = n_per_t * dk_idx + i; \
103+
if (s_idx >= Dk) continue; \
81104
state[i] *= {g_access}; \
82105
auto p = state[i] * k_[s_idx]; \
83106
auto a = p - kv_c; \
@@ -90,6 +113,7 @@ def _make_gated_delta_kernel(has_mask=False, vectorized=False):
90113
float out = 0.0f; \
91114
for (int i = 0; i < n_per_t; ++i) {{ \
92115
auto s_idx = n_per_t * dk_idx + i; \
116+
if (s_idx >= Dk) continue; \
93117
state[i] += k_[s_idx] * delta; \
94118
out += state[i] * q_[s_idx]; \
95119
}} \
@@ -114,7 +138,7 @@ def _make_gated_delta_kernel(has_mask=False, vectorized=False):
114138
#undef ADV
115139
for (int i = 0; i < n_per_t; ++i) {{
116140
auto s_idx = n_per_t * dk_idx + i;
117-
o_state[s_idx] = static_cast<StT>(state[i]);
141+
if (s_idx < Dk) o_state[s_idx] = static_cast<StT>(state[i]);
118142
}}
119143
"""
120144
inputs = ["q", "k", "v", "g", "beta", "state_in", "T"]
@@ -295,15 +319,15 @@ def gated_delta_update(
295319
if training:
296320
# Chunked VJP path with O(T/chunk) autodiff graph — fits T≥2048
297321
# on 36 GB Apple Silicon where the Python-ops path OOMs.
298-
# Metal backward kernel is 8–11× faster than the Python reference,
299-
# but only handles the unmasked GPU path with Dk%32==0 and Dv%4==0.
300-
Dk = q.shape[-1]
322+
# Metal backward kernel is 8–11× faster than the Python reference. It
323+
# now handles ANY Dk (in-MSL remainder-mask), but still needs Dv%4==0
324+
# for the four-SIMD-group cooperative reduction (ragged Dv backward
325+
# deferred). The unmasked GPU path is also required.
301326
Dv = v.shape[-1]
302327
can_use_metal = (
303328
mx.metal.is_available()
304329
and mx.default_device() == mx.gpu
305330
and mask is None
306-
and Dk % 32 == 0
307331
and Dv % 4 == 0
308332
)
309333
if can_use_metal:

0 commit comments

Comments
 (0)