Skip to content

Commit b6f8172

Browse files
committed
feat(v4): ROI 3.A — GDN Path A as minimal-edit port of FLA naive.py
Approach correction: instead of writing GDN recurrence from scratch, vendor fla-org/flash-linear-attention/fla/ops/gated_delta_rule/naive.py verbatim and apply only the edits MLX semantics require (torch->mx, .clone->noop, no in-place index assignment so o[:, :, i] = ... becomes append+stack, einops rearrange dropped, einsum expanded to broadcast+sum). Math, variable names, loop body, and final-state semantics preserved. cppmega_v4/nn/_external/fla_naive_gated_delta_rule.py: - Verbatim copy of FLA naive.py header + recurrent function with MIT attribution. Edits documented at top of file so a diff vs upstream is short. Chunk variant deferred (its in-place attn mutation needs more work). cppmega_v4/nn/linear_attention.py: - LinearAttentionConfig field names mirror fla.layers.gated_deltanet. GatedDeltaNet so a future swap to fused versions is a name-match (hidden_size, num_heads, head_dim, expand_v, num_v_heads, use_short_conv, conv_size, use_gate, norm_eps). - LinearAttentionBlock uses standard MLX nn.Linear + nn.RMSNorm plus a tiny causal short-conv ported from cppmega_mlx.nn.engram. FusedRMSNormGated and ShortConvolution (FLA Triton optimizations) deferred to Path C. - doc_ids: explicit per-segment recurrence so state does not cross document boundaries. cu_seqlens-style packed-batch API deferred. mtp_v4.py: docstring clarified — V3 architecture inherited unchanged in V4 (verified: TileKernels modeling/ has no MTP module, DeepSeek-V3.2-Exp has no MTP, DeepSeek-V3/inference/model.py is training-time only). 19 new tests in tests/v4/test_linear_attention_path_a.py: - Config validation (6 invalid combos rejected) - Derived dims (head_v_dim, key_dim, value_dim) match FLA arithmetic - num_v_heads divisibility validated - naive_recurrent shape contract; initial_state carry; output_final_state - *** Numerical parity vs PyTorch fla.ops.gated_delta_rule.naive. naive_recurrent_gated_delta_rule on identical inputs (rtol 1e-4 atol 1e-5) *** - Block forward shape, rank/dim validation, identity-at-init - Short-conv path runs - doc_ids changes output; shape validation - Gradient flows into q/k/v/a/b/o projections 109/109 regression green (v4 + extensions + engram + nam pattern + mtp). Pipeline: Implementer (port FLA naive verbatim with minimal edits) -> Code Review (clean, attribution header, no invented math) -> Perf Optimization (none — Path A is golden reference, perf is Path B/C/D/E job) -> Regression Tests (parity vs FLA torch passes at 1e-5).
1 parent af7ca0e commit b6f8172

5 files changed

Lines changed: 589 additions & 1 deletion

File tree

cppmega_v4/nn/_external/__init__.py

Whitespace-only changes.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Verbatim port of fla-org/flash-linear-attention/fla/ops/gated_delta_rule/naive.py
2+
# with the smallest possible edits to run on MLX instead of PyTorch.
3+
#
4+
# Upstream source:
5+
# /Volumes/external/sources/rent_kernels/flash-linear-attention/
6+
# fla/ops/gated_delta_rule/naive.py
7+
# Upstream license: MIT (Songlin Yang, Yu Zhang, Zhiyuan Li — 2023-2026)
8+
# Upstream copyright header retained below.
9+
#
10+
# Edits made (kept minimal so a diff against the upstream file is short):
11+
# - import torch -> import mlx.core as mx
12+
# - import torch.nn.functional as F -> removed (only used in chunk variant)
13+
# - from einops import rearrange -> removed (replaced by inline reshape)
14+
# - torch.Tensor type annotations -> mx.array
15+
# - .transpose(1, 2).contiguous() -> mx.transpose(x, (0, 2, 1, *rest))
16+
# - .to(torch.float32) -> .astype(mx.float32)
17+
# - .to(v) -> .astype(v.dtype)
18+
# - .clone() -> dropped (MLX is functional, no aliasing)
19+
# - .unsqueeze(-1) -> [..., None]
20+
# - torch.zeros(...).to(v) -> mx.zeros(shape, dtype=v.dtype)
21+
# - torch.einsum('bhd,bhdm->bhm',
22+
# b_q, h) -> mx.sum(b_q[..., :, None] * h, axis=-2)
23+
# - o[:, :, i] = ... -> append to list, mx.stack at end
24+
# (MLX is functional and cannot assign by index)
25+
# Algorithm, variable names, loop body, and final-state semantics unchanged.
26+
#
27+
# Chunk variant (naive_chunk_gated_delta_rule) is NOT ported here yet —
28+
# its in-place mutation of `attn[..., i, :i]` requires a deeper rewrite that
29+
# is out of scope for this minimal-edit port.
30+
31+
# Copyright (c) 2023-2026, Songlin Yang, Yu Zhang, Zhiyuan Li
32+
#
33+
# This source code is licensed under the MIT license found in the
34+
# LICENSE file in the root directory of this source tree.
35+
# For a list of all contributors, visit:
36+
# https://github.com/fla-org/flash-linear-attention/graphs/contributors
37+
38+
import mlx.core as mx
39+
40+
41+
def naive_recurrent_gated_delta_rule(
42+
q: mx.array,
43+
k: mx.array,
44+
v: mx.array,
45+
beta: mx.array,
46+
g: mx.array,
47+
scale: float = None,
48+
initial_state: mx.array = None,
49+
output_final_state: bool = False,
50+
):
51+
"""
52+
Reference PyTorch implementation of recurrent gated delta rule.
53+
54+
Args:
55+
q: [B, T, H, K]
56+
k: [B, T, H, K]
57+
v: [B, T, H, V]
58+
beta: [B, T, H]
59+
g: [B, T, H]
60+
scale: float, optional
61+
initial_state: [B, H, K, V], optional
62+
output_final_state: bool
63+
64+
Returns:
65+
o: [B, T, H, V]
66+
final_state: [B, H, K, V] if output_final_state else None
67+
"""
68+
def _t12(x): # ".transpose(1, 2).contiguous()" — supports rank 3 and 4.
69+
perm = (0, 2, 1) + tuple(range(3, x.ndim))
70+
return mx.transpose(x, perm).astype(mx.float32)
71+
q, k, v, beta, g = map(_t12, [q, k, v, beta, g])
72+
B, H, T, K, V = *k.shape, v.shape[-1]
73+
o_list = []
74+
h = mx.zeros((B, H, K, V), dtype=v.dtype)
75+
if initial_state is not None:
76+
h = initial_state.astype(mx.float32)
77+
if scale is None:
78+
scale = 1 / (q.shape[-1] ** 0.5)
79+
q = q * scale
80+
81+
for i in range(T):
82+
b_q = q[:, :, i]
83+
b_k = k[:, :, i]
84+
b_v = v[:, :, i]
85+
h = h * mx.exp(g[:, :, i])[..., None, None]
86+
b_beta = beta[:, :, i]
87+
b_v = b_v - (h * b_k[..., None]).sum(-2)
88+
b_v = b_v * b_beta[..., None]
89+
h = h + b_k[..., None] * b_v[..., None, :]
90+
# einsum('bhd,bhdm->bhm', b_q, h): contract d -> output (B,H,V)
91+
o_list.append(mx.sum(b_q[..., :, None] * h, axis=-2))
92+
93+
if not output_final_state:
94+
h = None
95+
# stack list along the time axis (originally o[:, :, i] = ...) then
96+
# transpose back to [B, T, H, V] to match upstream return shape.
97+
o = mx.stack(o_list, axis=2)
98+
o = mx.transpose(o, (0, 2, 1, 3))
99+
return o, h

cppmega_v4/nn/linear_attention.py

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
"""GatedDeltaNet block plugin (Path A entry — naive recurrent kernel).
2+
3+
Minimal block wrapper around the FLA ``naive_recurrent_gated_delta_rule``
4+
math primitive (vendored in ``cppmega_v4/nn/_external/`` with MIT
5+
attribution). Field names are kept identical to FLA's
6+
``fla.layers.gated_deltanet.GatedDeltaNet`` (``q_proj``, ``k_proj``,
7+
``v_proj``, ``a_proj``, ``b_proj``, ``o_proj``, ``o_norm``) so a future
8+
swap to the fused-kernel layer is a name-match drop-in.
9+
10+
What this wrapper DELIBERATELY does NOT do yet (deferred to Path B/C/D/E):
11+
- FusedRMSNormGated (FLA Triton fusion) — we use plain RMSNorm here.
12+
- ShortConvolution (FLA Triton kernel) — we use a tiny pure-MLX
13+
causal depthwise conv copied from cppmega_mlx.nn.engram (same idiom).
14+
- dt_bias / A_log learnable scalars (FLA training-only quality knob).
15+
- Allow-negative-eigval branch.
16+
- cu_seqlens packed-batch path (we expose ``doc_ids`` instead).
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from dataclasses import dataclass
22+
23+
import mlx.core as mx
24+
import mlx.nn as nn
25+
26+
from cppmega_v4.nn._external.fla_naive_gated_delta_rule import (
27+
naive_recurrent_gated_delta_rule,
28+
)
29+
30+
31+
@dataclass(frozen=True)
32+
class LinearAttentionConfig:
33+
"""Static-shape config for the GDN linear-attention block.
34+
35+
Field names mirror ``fla.layers.gated_deltanet.GatedDeltaNet.__init__``
36+
where possible (``hidden_size``, ``num_heads``, ``head_dim``,
37+
``expand_v``, ``use_short_conv``, ``conv_size``, ``use_gate``,
38+
``norm_eps``).
39+
"""
40+
41+
hidden_size: int
42+
num_heads: int = 4
43+
head_dim: int = 32
44+
expand_v: float = 1.0
45+
num_v_heads: int | None = None
46+
use_short_conv: bool = True
47+
conv_size: int = 4
48+
use_gate: bool = False
49+
norm_eps: float = 1e-6
50+
51+
def __post_init__(self) -> None:
52+
if self.hidden_size <= 0:
53+
raise ValueError("hidden_size must be positive")
54+
if self.num_heads <= 0:
55+
raise ValueError("num_heads must be positive")
56+
if self.head_dim <= 0:
57+
raise ValueError("head_dim must be positive")
58+
if self.expand_v <= 0:
59+
raise ValueError("expand_v must be positive")
60+
nv = self.num_heads if self.num_v_heads is None else self.num_v_heads
61+
if nv <= 0:
62+
raise ValueError("num_v_heads must be positive when set")
63+
if nv > self.num_heads and nv % self.num_heads != 0:
64+
raise ValueError("num_v_heads must be divisible by num_heads")
65+
if self.conv_size < 0:
66+
raise ValueError("conv_size must be non-negative")
67+
if self.norm_eps <= 0:
68+
raise ValueError("norm_eps must be positive")
69+
70+
@property
71+
def head_k_dim(self) -> int:
72+
return self.head_dim
73+
74+
@property
75+
def head_v_dim(self) -> int:
76+
return int(self.head_dim * self.expand_v)
77+
78+
@property
79+
def key_dim(self) -> int:
80+
return self.num_heads * self.head_k_dim
81+
82+
@property
83+
def _num_v_heads(self) -> int:
84+
return self.num_heads if self.num_v_heads is None else self.num_v_heads
85+
86+
@property
87+
def value_dim(self) -> int:
88+
return self._num_v_heads * self.head_v_dim
89+
90+
91+
def _causal_short_conv(x: mx.array, weight: mx.array) -> mx.array:
92+
"""Depthwise causal conv1d via shift-and-add.
93+
94+
x: (B, S, C); weight: (C, K, 1). Matches the idiom already in
95+
``cppmega_mlx.nn.engram.causal_depthwise_silu_conv1d`` for consistency.
96+
"""
97+
kernel = weight.shape[1]
98+
if kernel == 1:
99+
return x * weight[:, 0, 0].reshape(1, 1, -1)
100+
seq_len = x.shape[1]
101+
out = mx.zeros_like(x)
102+
for shift in range(kernel):
103+
if shift == 0:
104+
shifted = x
105+
elif shift < seq_len:
106+
shifted = mx.pad(x[:, :-shift, :], [(0, 0), (shift, 0), (0, 0)])
107+
else:
108+
shifted = mx.zeros_like(x)
109+
tap = weight[:, kernel - 1 - shift, 0].reshape(1, 1, -1)
110+
out = out + shifted * tap
111+
return out
112+
113+
114+
class LinearAttentionBlock(nn.Module):
115+
"""GDN block. Forward: ``(B, S, hidden_size) -> (B, S, hidden_size)``.
116+
117+
Backend: Path A (FLA naive recurrent). Path B/C/D/E land under
118+
``cppmega_v4/_tilelang/`` and dispatch via env override in a follow-up ROI;
119+
until then this block always runs Path A.
120+
"""
121+
122+
def __init__(self, config: LinearAttentionConfig):
123+
super().__init__()
124+
self.config = config
125+
d = config.hidden_size
126+
# Field names match FLA: q_proj, k_proj, v_proj, a_proj, b_proj, o_proj.
127+
self.q_proj = nn.Linear(d, config.key_dim, bias=False)
128+
self.k_proj = nn.Linear(d, config.key_dim, bias=False)
129+
self.v_proj = nn.Linear(d, config.value_dim, bias=False)
130+
self.a_proj = nn.Linear(d, config._num_v_heads, bias=False)
131+
self.b_proj = nn.Linear(d, config._num_v_heads, bias=False)
132+
if config.use_gate:
133+
self.g_proj = nn.Linear(d, config.value_dim, bias=False)
134+
if config.use_short_conv and config.conv_size > 0:
135+
# Identity-initialized depthwise short conv (center tap = 1).
136+
self.q_conv_weight = self._init_id_conv_weight(config.key_dim, config.conv_size)
137+
self.k_conv_weight = self._init_id_conv_weight(config.key_dim, config.conv_size)
138+
self.v_conv_weight = self._init_id_conv_weight(config.value_dim, config.conv_size)
139+
self.o_proj = nn.Linear(config.value_dim, d, bias=False)
140+
# Zero-init out projection so block is identity at init.
141+
self.o_proj.weight = mx.zeros_like(self.o_proj.weight)
142+
self.o_norm = nn.RMSNorm(d, eps=config.norm_eps)
143+
144+
@staticmethod
145+
def _init_id_conv_weight(channels: int, kernel: int) -> mx.array:
146+
center = kernel - 1 # causal: last tap is "current"
147+
w = mx.zeros((channels, kernel, 1), dtype=mx.float32)
148+
w_np = w.tolist()
149+
for c in range(channels):
150+
w_np[c][center][0] = 1.0
151+
return mx.array(w_np, dtype=mx.float32)
152+
153+
def __call__(self, x: mx.array, *, doc_ids: mx.array | None = None) -> mx.array:
154+
if x.ndim != 3:
155+
raise ValueError(f"x must be shaped (B, S, D), got {x.shape}")
156+
if x.shape[-1] != self.config.hidden_size:
157+
raise ValueError(
158+
f"x last dim must be {self.config.hidden_size}, got {x.shape[-1]}"
159+
)
160+
cfg = self.config
161+
batch, seq_len, _ = x.shape
162+
163+
q = self.q_proj(x)
164+
k = self.k_proj(x)
165+
v = self.v_proj(x)
166+
if cfg.use_short_conv and cfg.conv_size > 0:
167+
q = _causal_short_conv(q, self.q_conv_weight.astype(q.dtype))
168+
k = _causal_short_conv(k, self.k_conv_weight.astype(k.dtype))
169+
v = _causal_short_conv(v, self.v_conv_weight.astype(v.dtype))
170+
171+
# FLA layout: q/k/v ∈ [B, T, H, K|V]
172+
q = q.reshape(batch, seq_len, cfg.num_heads, cfg.head_k_dim)
173+
k = k.reshape(batch, seq_len, cfg.num_heads, cfg.head_k_dim)
174+
v = v.reshape(batch, seq_len, cfg._num_v_heads, cfg.head_v_dim)
175+
176+
# a → gate decay logit; b → beta (learning rate). Sigmoid per FLA layer.
177+
beta = mx.sigmoid(self.b_proj(x)) # (B, T, num_v_heads)
178+
# FLA uses ``-softplus(a_proj + dt_bias)`` for gate (with dt_bias). We
179+
# match the simpler "g = log(sigmoid(a_proj))" until the dt_bias path
180+
# lands as part of a fused upgrade.
181+
g = mx.log(mx.sigmoid(self.a_proj(x)) + cfg.norm_eps) # (B, T, num_v_heads)
182+
183+
if doc_ids is None:
184+
o, _ = naive_recurrent_gated_delta_rule(q, k, v, beta, g)
185+
else:
186+
# Document-boundary state reset: split into runs of contiguous
187+
# doc_id, recur within each, concatenate outputs.
188+
o = self._recurrent_with_doc_reset(q, k, v, beta, g, doc_ids)
189+
190+
# o has shape [B, T, H_v, V_dim]; flatten head axis for o_proj.
191+
o = o.reshape(batch, seq_len, cfg.value_dim)
192+
o = self.o_proj(o)
193+
return self.o_norm(o)
194+
195+
@staticmethod
196+
def _recurrent_with_doc_reset(
197+
q: mx.array,
198+
k: mx.array,
199+
v: mx.array,
200+
beta: mx.array,
201+
g: mx.array,
202+
doc_ids: mx.array,
203+
) -> mx.array:
204+
"""Apply naive recurrent kernel per document segment.
205+
206+
We loop over batch entries and over contiguous doc-id runs. This is
207+
slow (O(B * num_segments) Python iterations) but correct; a fused
208+
cu_seqlens-style API ports later when Path C lands.
209+
"""
210+
if doc_ids.ndim != 2:
211+
raise ValueError(f"doc_ids must be 2D (B, T), got {doc_ids.shape}")
212+
batch, seq_len = doc_ids.shape
213+
if q.shape[0] != batch or q.shape[1] != seq_len:
214+
raise ValueError("doc_ids shape must match q's (B, T)")
215+
# Iterate batch entries explicitly; build a list of [1, run_len, H, *]
216+
# slices per run and concatenate along T.
217+
ids_np = doc_ids.tolist()
218+
per_batch_outputs: list[mx.array] = []
219+
for b in range(batch):
220+
row = ids_np[b]
221+
runs: list[tuple[int, int]] = []
222+
start = 0
223+
for t in range(1, seq_len):
224+
if row[t] != row[start]:
225+
runs.append((start, t))
226+
start = t
227+
runs.append((start, seq_len))
228+
run_outputs: list[mx.array] = []
229+
for s, e in runs:
230+
qb = q[b:b + 1, s:e]
231+
kb = k[b:b + 1, s:e]
232+
vb = v[b:b + 1, s:e]
233+
bb = beta[b:b + 1, s:e]
234+
gb = g[b:b + 1, s:e]
235+
ob, _ = naive_recurrent_gated_delta_rule(qb, kb, vb, bb, gb)
236+
run_outputs.append(ob)
237+
per_batch_outputs.append(mx.concatenate(run_outputs, axis=1))
238+
return mx.concatenate(per_batch_outputs, axis=0)
239+
240+
241+
__all__ = [
242+
"LinearAttentionConfig",
243+
"LinearAttentionBlock",
244+
"naive_recurrent_gated_delta_rule",
245+
]

cppmega_v4/nn/mtp_v4.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1-
"""DeepSeek-V3-style Sequential Multi-Token-Prediction head plugin.
1+
"""Sequential Multi-Token-Prediction head plugin (V3 architecture, inherited in V4).
22
33
The existing ``MinimalMTPHead`` (``cppmega_mlx/training/mtp.py``) reuses one
44
shared block recursively for every depth — a contracted reference. DeepSeek-V3
55
ships D **sequential transformer-style blocks**, one per future-token depth,
66
each with its own RMSNorms, projection, and transformer kernel, but sharing the
77
model's token embedding and lm head.
88
9+
V3 vs V4 status (as of May 2026 — public evidence only):
10+
The V4-Pro / V4-Flash configs and the available V4 paper (arxiv 2512.24880,
11+
which is the mHC paper, not a full V4 tech report) do not document any
12+
MTP-architecture change vs V3. V4 inherits this exact Sequential head
13+
unchanged. When a V4-specific MTP variant lands (full tech report or vLLM
14+
V4 inference path), add a ``SequentialMTPHeadV4`` sibling here.
15+
916
This plugin lands the V3-faithful surface side-by-side without touching the
1017
existing ``MinimalMTPHead``.
1118

0 commit comments

Comments
 (0)