Skip to content

Commit 4f0b5bc

Browse files
committed
feat(v4): ROI 8 NSA + ROI 9 CSA+HCA — real implementations (no more SDPA fallback)
Two new modules replace the dense-SDPA stubs with the actual sparse attention patterns from the V4 papers: ROI 8 — cppmega_v4.nn.nsa_v4.NativeSparseAttentionV4 (arxiv 2502.11089) Three real branches + learned softmax-gated mixture: - Compress: mean-pool K/V into coarse blocks (compress_block_size); query attends to all coarse blocks with causal-in-blocks mask. Returns block_scores for the Select branch. - Select: per (B,H,S_q) top-k coarse blocks by Compress-score; build a [B,H,S_q,S_kv] sparse mask covering selected blocks' token spans AND enforcing causality; SDPA with that mask. - Sliding: causal windowed attention over the last sliding_window tokens. Gate is a tiny Linear(d, 3) softmaxed per token — at zero-init all three branches contribute equally. ROI 9 — cppmega_v4.nn.csa_hca_v4.CSAHCAHybridV4 (arxiv 2512.24880) Real m-token KV compression at two ratios with shared q/k/v projections (DSV4 weight-sharing pattern): - CSA: K/V mean-pooled every m_csa tokens → ⌈S/m_csa⌉ super-tokens. - HCA: same with m_hca >> m_csa for very-long-ctx global summary. Causal-in-supertokens mask. Optional select_indices argument plugs in Lightning Indexer top-k filtering when caller wires it up. Outputs of the two branches are softmax-gated per token via Linear(d, 2). Both modules are drop-in replacements for the existing ``sparse_attention_v4.NativeSparseAttention`` / ``CsaHcaHybridAttention`` scaffolds (same (B, S, H) → (B, S, H) signature). Old modules retained for backward compat; new ones are imported under V4-suffixed names. Where pure MLX hits its limit: - The Select branch materializes the full [S_q, S_kv] sparse mask instead of running a block-sparse kernel. A Metal/TileLang fused block-sparse SDPA could replace this without changing the API (tracked: cppmega_v4._tilelang.nsa_path_c.py). - The Compress branch recomputes the mean-pool each forward; a streaming/segment-tree cache amortizes this on long contexts (tracked: ROI 8.B). Tests (22 new): NSA config validation, per-branch primitives (causal-in-blocks identity, top-k respects causality, sliding window=1 returns v), module shapes, gate balance at init, short-sequence degeneration; CSA+HCA config validation, _compress_kv mean-pool, _compressed_attention causal-in-super, select_indices filter, module shape, short-sequence handling. v4 suite: 254 passed / 2 skipped.
1 parent 98ef7b2 commit 4f0b5bc

4 files changed

Lines changed: 718 additions & 0 deletions

File tree

cppmega_v4/nn/csa_hca_v4.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""ROI 9 — CSA + HCA hybrid V4 attention, real compression impl.
2+
3+
DeepSeek-V4's attention stack pairs:
4+
5+
- CSA (Compressed Sparse Attention): KV is m-compressed (every m tokens
6+
averaged into one) AND filtered through a Lightning-Indexer top-k.
7+
Q sees ⌈S/m⌉ super-tokens.
8+
- HCA (Heavily Compressed Attention): same shape, larger m
9+
(m_heavy ≫ m_csa) — gives a coarse global summary for very-long ctx.
10+
- Hybrid: outputs of both are gated together with the optional MLA branch
11+
(handled by the caller — this module only does CSA+HCA).
12+
13+
This module replaces the dense-SDPA fallback in
14+
``sparse_attention_v4.CsaHcaHybridAttention`` with real m-token KV
15+
compression + gated mixture. The mean-pool compression matches the NSA
16+
Compress branch, just at a different (configurable) ratio.
17+
18+
Implementation notes:
19+
- We *do not* include the Lightning Indexer top-k inside this module —
20+
it composes externally via ``cppmega_v4.nn.lightning_indexer_fp8``.
21+
The hybrid module takes optional ``select_indices`` arguments to
22+
consume them when the indexer wires up. Without indices, attention
23+
runs over the full compressed-block set.
24+
- The two branches use the same q/k/v projections (the compression
25+
ratio differs, not the underlying state) — this matches the DSV4
26+
weight-sharing pattern (see arxiv:2512.24880).
27+
"""
28+
29+
from dataclasses import dataclass
30+
from typing import Optional
31+
32+
import mlx.core as mx
33+
import mlx.nn as nn
34+
35+
from cppmega_v4.nn.nsa_v4 import _apply_mask_and_softmax
36+
37+
38+
@dataclass(frozen=True)
39+
class CSAHCAConfig:
40+
"""CSA + HCA hybrid attention config (arxiv 2512.24880)."""
41+
42+
hidden_size: int
43+
num_heads: int
44+
head_dim: int
45+
m_csa: int = 4 # CSA compression ratio (tokens per super-token)
46+
m_hca: int = 16 # HCA compression ratio (much larger)
47+
norm_eps: float = 1e-6
48+
49+
def __post_init__(self) -> None:
50+
if self.hidden_size != self.num_heads * self.head_dim:
51+
raise ValueError(
52+
f"hidden_size {self.hidden_size} must equal "
53+
f"num_heads * head_dim ({self.num_heads * self.head_dim})"
54+
)
55+
if self.m_csa <= 0 or self.m_hca <= 0:
56+
raise ValueError("compression ratios must be positive")
57+
if self.m_hca < self.m_csa:
58+
raise ValueError(
59+
f"m_hca ({self.m_hca}) must be >= m_csa ({self.m_csa})"
60+
)
61+
62+
63+
def _compress_kv(
64+
k: mx.array, v: mx.array, m: int,
65+
) -> tuple[mx.array, mx.array, int]:
66+
"""Mean-pool K/V every m tokens. Pads with zeros if S % m != 0.
67+
68+
Returns (k_comp, v_comp, n_super) — both with shape [B, H, n_super, D].
69+
"""
70+
B, H, S, D = k.shape
71+
pad = (-S) % m
72+
if pad:
73+
k = mx.pad(k, ((0, 0), (0, 0), (0, pad), (0, 0)))
74+
v = mx.pad(v, ((0, 0), (0, 0), (0, pad), (0, 0)))
75+
n_super = (S + pad) // m
76+
k_c = k.reshape(B, H, n_super, m, D).mean(axis=3)
77+
v_c = v.reshape(B, H, n_super, m, D).mean(axis=3)
78+
return k_c, v_c, n_super
79+
80+
81+
def _compressed_attention(
82+
q: mx.array, k_c: mx.array, v_c: mx.array,
83+
*, m: int, original_seq: int, scale: float,
84+
select_indices: Optional[mx.array] = None,
85+
) -> mx.array:
86+
"""Attention from q [B,H,S_q,D] to compressed KV [B,H,n_super,D].
87+
88+
Causal-in-supertokens: query at position i sees super-token b iff
89+
b*m <= i (the *first* token of super-block b is no later than i).
90+
Optionally restrict to a top-k of super-tokens per query via
91+
``select_indices`` (shape [B,H,S_q,k] int32, from an external indexer).
92+
"""
93+
B, H, S_q, D = q.shape
94+
n_super = k_c.shape[2]
95+
scores = mx.matmul(q, mx.transpose(k_c, (0, 1, 3, 2))) # [B,H,S_q,n_super]
96+
# Causal mask in supertokens.
97+
rows = mx.arange(S_q)[:, None]
98+
super_start = (mx.arange(n_super)[None, :]) * m
99+
causal_mask = (rows >= super_start)[None, None, :, :] # [1,1,S_q,n_super]
100+
# Optional top-k mask from external indexer.
101+
if select_indices is not None:
102+
all_super = mx.arange(n_super)[None, None, None, :]
103+
match = (select_indices[..., :, None] == all_super[..., None, :])
104+
topk_mask = match.any(axis=-2)
105+
final_mask = causal_mask & topk_mask
106+
else:
107+
final_mask = causal_mask
108+
weights = _apply_mask_and_softmax(scores, final_mask, scale)
109+
return mx.matmul(weights, v_c) # [B,H,S_q,D]
110+
111+
112+
class CSAHCAHybridV4(nn.Module):
113+
"""Real CSA + HCA hybrid attention: two compression ratios, gated mixture.
114+
115+
Drop-in for ``sparse_attention_v4.CsaHcaHybridAttention`` (same I/O).
116+
Branch outputs are softmax-gated per token.
117+
"""
118+
119+
def __init__(self, config: CSAHCAConfig):
120+
super().__init__()
121+
self.config = config
122+
d = config.hidden_size
123+
self.q_proj = nn.Linear(d, d, bias=False)
124+
self.k_proj = nn.Linear(d, d, bias=False)
125+
self.v_proj = nn.Linear(d, d, bias=False)
126+
self.o_proj = nn.Linear(d, d, bias=False)
127+
self.norm = nn.RMSNorm(d, eps=config.norm_eps)
128+
# 2-way gate over [csa, hca] per token.
129+
self.branch_gate = nn.Linear(d, 2, bias=False)
130+
131+
def __call__(
132+
self,
133+
x: mx.array,
134+
*,
135+
csa_select_indices: Optional[mx.array] = None,
136+
hca_select_indices: Optional[mx.array] = None,
137+
) -> mx.array:
138+
"""Forward.
139+
140+
Args:
141+
x: [B, S, hidden_size].
142+
csa_select_indices: optional [B, H, S, k] top-k super-token indices
143+
from a Lightning Indexer on the CSA-compressed KV. When None
144+
CSA attends to all super-tokens (causal).
145+
hca_select_indices: same, for HCA.
146+
"""
147+
cfg = self.config
148+
if x.ndim != 3 or x.shape[-1] != cfg.hidden_size:
149+
raise ValueError(
150+
f"x must be [B, S, {cfg.hidden_size}], got {x.shape}"
151+
)
152+
B, S, _ = x.shape
153+
q = self.q_proj(x).reshape(B, S, cfg.num_heads, cfg.head_dim)
154+
k = self.k_proj(x).reshape(B, S, cfg.num_heads, cfg.head_dim)
155+
v = self.v_proj(x).reshape(B, S, cfg.num_heads, cfg.head_dim)
156+
q = mx.transpose(q, (0, 2, 1, 3))
157+
k = mx.transpose(k, (0, 2, 1, 3))
158+
v = mx.transpose(v, (0, 2, 1, 3))
159+
scale = cfg.head_dim ** -0.5
160+
161+
# CSA branch.
162+
k_c, v_c, _ = _compress_kv(k, v, cfg.m_csa)
163+
csa_out = _compressed_attention(
164+
q, k_c, v_c, m=cfg.m_csa, original_seq=S, scale=scale,
165+
select_indices=csa_select_indices,
166+
)
167+
# HCA branch.
168+
k_h, v_h, _ = _compress_kv(k, v, cfg.m_hca)
169+
hca_out = _compressed_attention(
170+
q, k_h, v_h, m=cfg.m_hca, original_seq=S, scale=scale,
171+
select_indices=hca_select_indices,
172+
)
173+
174+
# Gate (2-way softmax).
175+
gate_logits = self.branch_gate(x) # [B, S, 2]
176+
gate = mx.softmax(gate_logits.astype(mx.float32), axis=-1).astype(x.dtype)
177+
# Branch outputs are [B, H, S, D]; rearrange + mix.
178+
branches = mx.stack([
179+
mx.transpose(csa_out, (0, 2, 1, 3)),
180+
mx.transpose(hca_out, (0, 2, 1, 3)),
181+
], axis=-1) # [B,S,H,D,2]
182+
mixed = (branches * gate[:, :, None, None, :]).sum(axis=-1)
183+
out = mixed.reshape(B, S, cfg.hidden_size)
184+
return self.norm(self.o_proj(out))
185+
186+
187+
__all__ = [
188+
"CSAHCAConfig",
189+
"CSAHCAHybridV4",
190+
"_compress_kv",
191+
"_compressed_attention",
192+
]

0 commit comments

Comments
 (0)