Skip to content

Commit e09461c

Browse files
committed
feat(v4): Lightning Indexer → CSA+HCA select-indices adapter
CSAHCAHybridV4.__call__ takes optional csa_select_indices / hca_select_indices [B, H, S, k_super] arguments but nothing was producing them. LightningIndexerFP8 produces [B, S, top_k_tokens] int32 token-level indices. This adapter projects the latter to the former and wires both into one call. Public API: apply_indexer_to_csa_hca(indexer, csa_hca, x, qr, freqs_cis, *, mask=None, k_super_csa=None, k_super_hca=None, num_heads=None) → [B, S, hidden] Projection logic (`_tokens_to_super_indices`): 1. super_idx = token_idx // m_csa (per-element floor-div) 2. dedup per (b, s) — multiple top-tokens collapse onto the same super-block when m > 1; keep first-seen. 3. pad with last-seen if fewer than k_super distinct super-tokens were found (idempotent OR'd mask downstream). 4. truncate to k_super and broadcast over H heads. Defaults: k_super_csa = indexer.config.index_topk // m_csa (min 1) k_super_hca = same with m_hca num_heads = csa_hca.config.num_heads Tests (7 new): - projection: floor-div, dedup+pad, broadcast across heads, validation. - end-to-end with fp32 LightningIndexer. - end-to-end with fp8 LightningIndexerFP8. - parity: k_super=1 restricts attention and changes output vs unrestricted CSA+HCA call (proves indices flow through). v4 suite: 310 passed / 2 skipped.
1 parent d948cbc commit e09461c

2 files changed

Lines changed: 291 additions & 0 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""Adapter: Lightning Indexer top-k tokens → CSA/HCA super-token indices.
2+
3+
LightningIndexerFP8 (cppmega_v4/nn/lightning_indexer_fp8.py) returns
4+
``[B, S_q, top_k_tokens]`` int32 token-level indices ("top-k positions
5+
to attend to"). CSAHCAHybridV4 (cppmega_v4/nn/csa_hca_v4.py) expects
6+
``[B, H, S_q, k_super]`` int32 *super-token* indices for the
7+
``csa_select_indices`` / ``hca_select_indices`` arguments — one super-
8+
token is ``m_csa`` (or ``m_hca``) consecutive tokens.
9+
10+
This module provides the projection:
11+
12+
super_idx[b, h, s, k_super] = token_idx[b, s, k_token] // m
13+
14+
with optional dedup-and-pad so each query sees ``k_super`` *distinct*
15+
super-tokens (Lightning's top-k may collapse onto the same super-block
16+
when ``m > 1``).
17+
18+
Public API:
19+
apply_indexer_to_csa_hca(indexer, csa_hca, x, qr, freqs_cis,
20+
*, k_super_csa=None, k_super_hca=None,
21+
num_heads=None) → out: [B, S, hidden_size]
22+
23+
The function wires the indexer's output through CSAHCAHybridV4 with
24+
both select-indices populated. ``k_super_csa`` defaults to
25+
``indexer.config.index_topk // m_csa`` (rounded down, min 1); same for HCA.
26+
``num_heads`` defaults to ``csa_hca.config.num_heads`` (broadcast over H).
27+
"""
28+
29+
from typing import Optional
30+
31+
import mlx.core as mx
32+
33+
from cppmega_v4.nn.csa_hca_v4 import CSAHCAHybridV4
34+
from cppmega_v4.nn.lightning_indexer import LightningIndexer
35+
from cppmega_v4.nn.lightning_indexer_fp8 import LightningIndexerFP8
36+
37+
38+
def _tokens_to_super_indices(
39+
token_indices: mx.array, # [B, S_q, top_k_tokens] int32
40+
m: int,
41+
k_super: int,
42+
num_heads: int,
43+
) -> mx.array:
44+
"""Map per-token top-k indices to per-(super-token) top-k indices.
45+
46+
Steps:
47+
1. Divide each token index by m → super-token index.
48+
2. Per (b, s), dedup adjacent super-token indices to avoid attending
49+
to the same super-token twice when several top-tokens collapse
50+
onto the same block. (Simple O(top_k) pass; we keep first-seen.)
51+
3. If fewer than k_super distinct super-tokens were found, pad with
52+
the last-seen (the dedup outcome — repeated indices in the final
53+
mask are harmless because the OR'd token_mask in CSA's select
54+
branch is idempotent).
55+
4. Truncate to k_super entries per (b, s).
56+
5. Broadcast across num_heads → [B, H, S, k_super].
57+
"""
58+
if m <= 0:
59+
raise ValueError(f"m must be positive, got {m}")
60+
if k_super <= 0:
61+
raise ValueError(f"k_super must be positive, got {k_super}")
62+
if num_heads <= 0:
63+
raise ValueError(f"num_heads must be positive, got {num_heads}")
64+
65+
# 1. Token → super-token via floor-div by m.
66+
super_tok = token_indices.astype(mx.int32) // m # [B, S, top_k_tokens]
67+
68+
# 2-4. Dedup via Python loop on small last dim. top_k is typically
69+
# small (≤64) so this is cheap and exact.
70+
B, S, top_k_tokens = super_tok.shape
71+
arr = mx.array(super_tok) # ensure eager
72+
# Build numpy view for the dedup pass (tiny array, no perf concern).
73+
import numpy as np
74+
arr_np = np.array(arr)
75+
out_np = np.zeros((B, S, k_super), dtype=np.int32)
76+
for b in range(B):
77+
for s in range(S):
78+
seen: list[int] = []
79+
for k in range(top_k_tokens):
80+
idx = int(arr_np[b, s, k])
81+
if idx not in seen:
82+
seen.append(idx)
83+
if len(seen) == k_super:
84+
break
85+
# Pad with last-seen if we ran out of unique indices.
86+
if not seen:
87+
seen = [0]
88+
while len(seen) < k_super:
89+
seen.append(seen[-1])
90+
out_np[b, s, :] = seen[:k_super]
91+
super_dedup = mx.array(out_np) # [B, S, k_super]
92+
93+
# 5. Broadcast across H → [B, H, S, k_super].
94+
out = mx.broadcast_to(
95+
super_dedup[:, None, :, :], (B, num_heads, S, k_super),
96+
)
97+
return out
98+
99+
100+
def apply_indexer_to_csa_hca(
101+
indexer: LightningIndexer | LightningIndexerFP8,
102+
csa_hca: CSAHCAHybridV4,
103+
x: mx.array,
104+
qr: mx.array,
105+
freqs_cis: tuple[mx.array, mx.array],
106+
*,
107+
mask: Optional[mx.array] = None,
108+
k_super_csa: Optional[int] = None,
109+
k_super_hca: Optional[int] = None,
110+
num_heads: Optional[int] = None,
111+
) -> mx.array:
112+
"""Run Lightning Indexer, project its top-k to CSA+HCA super-tokens,
113+
then call CSAHCAHybridV4 with both select-indices populated.
114+
115+
Args:
116+
indexer: an instantiated LightningIndexer / LightningIndexerFP8.
117+
csa_hca: an instantiated CSAHCAHybridV4.
118+
x: [B, S, hidden_size] hidden states.
119+
qr: [B, S, q_lora_rank] LoRA-reduced query features.
120+
freqs_cis: (cos, sin) tuple for the indexer's RoPE; each
121+
``[S, rope_head_dim/2]``.
122+
mask: optional additive mask for the indexer's logits.
123+
k_super_csa / k_super_hca: number of super-tokens to select per
124+
(b, h, s); defaults to ``index_topk // m`` (min 1).
125+
num_heads: number of heads to broadcast indices across; defaults
126+
to ``csa_hca.config.num_heads``.
127+
128+
Returns:
129+
out: [B, S, hidden_size] — CSAHCAHybridV4 output.
130+
"""
131+
cfg = csa_hca.config
132+
if num_heads is None:
133+
num_heads = cfg.num_heads
134+
135+
token_indices = indexer(x, qr, freqs_cis, mask=mask) # [B, S, top_k_tokens]
136+
idx_topk = indexer.config.index_topk
137+
138+
if k_super_csa is None:
139+
k_super_csa = max(1, idx_topk // cfg.m_csa)
140+
if k_super_hca is None:
141+
k_super_hca = max(1, idx_topk // cfg.m_hca)
142+
143+
csa_sel = _tokens_to_super_indices(token_indices, cfg.m_csa,
144+
k_super_csa, num_heads)
145+
hca_sel = _tokens_to_super_indices(token_indices, cfg.m_hca,
146+
k_super_hca, num_heads)
147+
148+
return csa_hca(x, csa_select_indices=csa_sel, hca_select_indices=hca_sel)
149+
150+
151+
__all__ = ["apply_indexer_to_csa_hca", "_tokens_to_super_indices"]
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Tests for the Lightning Indexer → CSA+HCA select-indices adapter."""
2+
3+
import mlx.core as mx
4+
import numpy as np
5+
import pytest
6+
7+
from cppmega_v4.nn.csa_hca_indexer_adapter import (
8+
_tokens_to_super_indices,
9+
apply_indexer_to_csa_hca,
10+
)
11+
from cppmega_v4.nn.csa_hca_v4 import CSAHCAConfig, CSAHCAHybridV4
12+
from cppmega_v4.nn.lightning_indexer import (
13+
LightningIndexer,
14+
LightningIndexerConfig,
15+
)
16+
from cppmega_v4.nn.lightning_indexer_fp8 import (
17+
LightningIndexerFP8,
18+
LightningIndexerFP8Config,
19+
)
20+
21+
22+
# ----- Token → super-token projection -----
23+
24+
25+
def test_token_to_super_floor_div():
26+
"""super_idx[b, s, k] = token[b, s, k] // m, dedup then broadcast."""
27+
# B=1, S=2, top_k=4; m=4; k_super=2; H=1
28+
tok = mx.array([[[0, 3, 8, 9], [4, 5, 6, 7]]], dtype=mx.int32) # [1,2,4]
29+
out = _tokens_to_super_indices(tok, m=4, k_super=2, num_heads=1)
30+
# tok//4 = [[[0,0,2,2],[1,1,1,1]]], dedup → [[[0,2],[1,1]]]
31+
expected = np.array([[[[0, 2], [1, 1]]]], dtype=np.int32) # [1,1,2,2]
32+
assert out.shape == (1, 1, 2, 2)
33+
np.testing.assert_array_equal(np.array(out), expected)
34+
35+
36+
def test_token_to_super_dedup_and_pad():
37+
"""All top-k tokens map to the same super → pad with the dedup result."""
38+
tok = mx.array([[[0, 1, 2, 3]]], dtype=mx.int32) # all in super 0
39+
out = _tokens_to_super_indices(tok, m=8, k_super=3, num_heads=1)
40+
# tok//8 = [[[0,0,0,0]]] → dedup [0] → pad to k_super=3 → [0,0,0]
41+
np.testing.assert_array_equal(
42+
np.array(out), np.array([[[[0, 0, 0]]]], dtype=np.int32),
43+
)
44+
45+
46+
def test_token_to_super_broadcasts_across_heads():
47+
tok = mx.array([[[0, 8, 16]]], dtype=mx.int32)
48+
out = _tokens_to_super_indices(tok, m=8, k_super=3, num_heads=4)
49+
assert out.shape == (1, 4, 1, 3)
50+
# All H heads see the same indices.
51+
for h in range(4):
52+
np.testing.assert_array_equal(
53+
np.array(out[0, h, 0]), np.array(out[0, 0, 0]),
54+
)
55+
56+
57+
def test_token_to_super_validation():
58+
tok = mx.array([[[0]]], dtype=mx.int32)
59+
with pytest.raises(ValueError, match="m must be positive"):
60+
_tokens_to_super_indices(tok, m=0, k_super=1, num_heads=1)
61+
with pytest.raises(ValueError, match="k_super must be positive"):
62+
_tokens_to_super_indices(tok, m=2, k_super=0, num_heads=1)
63+
with pytest.raises(ValueError, match="num_heads must be positive"):
64+
_tokens_to_super_indices(tok, m=2, k_super=1, num_heads=0)
65+
66+
67+
# ----- End-to-end: indexer (fp32) + CSA+HCA -----
68+
69+
70+
def _freqs(seq, rope_half, seed=0):
71+
rng = np.random.default_rng(seed)
72+
cos = mx.array(rng.uniform(-1, 1, (seq, rope_half)).astype(np.float32))
73+
sin = mx.array(rng.uniform(-1, 1, (seq, rope_half)).astype(np.float32))
74+
return cos, sin
75+
76+
77+
def test_end_to_end_with_lightning_indexer_fp32():
78+
B, S, H, D = 1, 16, 4, 16
79+
hidden = H * D
80+
indexer = LightningIndexer(LightningIndexerConfig(
81+
hidden_size=hidden, n_heads=2, head_dim=32, rope_head_dim=16,
82+
q_lora_rank=hidden, index_topk=8,
83+
))
84+
csa_hca = CSAHCAHybridV4(CSAHCAConfig(
85+
hidden_size=hidden, num_heads=H, head_dim=D, m_csa=2, m_hca=4,
86+
))
87+
88+
x = mx.random.normal((B, S, hidden))
89+
qr = mx.random.normal((B, S, hidden))
90+
cos, sin = _freqs(S, 8)
91+
92+
out = apply_indexer_to_csa_hca(indexer, csa_hca, x, qr, (cos, sin))
93+
assert out.shape == (B, S, hidden)
94+
assert not bool(mx.any(mx.isnan(out)).item())
95+
96+
97+
def test_end_to_end_with_lightning_indexer_fp8():
98+
B, S, H, D = 1, 16, 4, 16
99+
hidden = H * D
100+
indexer = LightningIndexerFP8(LightningIndexerFP8Config(
101+
hidden_size=hidden, n_heads=2, head_dim=32, rope_head_dim=16,
102+
q_lora_rank=hidden, index_topk=8, fp8_blocks=True,
103+
))
104+
csa_hca = CSAHCAHybridV4(CSAHCAConfig(
105+
hidden_size=hidden, num_heads=H, head_dim=D, m_csa=2, m_hca=4,
106+
))
107+
108+
x = mx.random.normal((B, S, hidden))
109+
qr = mx.random.normal((B, S, hidden))
110+
cos, sin = _freqs(S, 8)
111+
112+
out = apply_indexer_to_csa_hca(indexer, csa_hca, x, qr, (cos, sin))
113+
assert out.shape == (B, S, hidden)
114+
assert not bool(mx.any(mx.isnan(out)).item())
115+
116+
117+
def test_select_indices_actually_restricts_attention():
118+
"""Force k_super=1: each query sees exactly one super-token. The CSA+HCA
119+
output then differs from the unrestricted (all-super) call."""
120+
B, S, H, D = 1, 16, 2, 8
121+
hidden = H * D
122+
indexer = LightningIndexer(LightningIndexerConfig(
123+
hidden_size=hidden, n_heads=2, head_dim=16, rope_head_dim=8,
124+
q_lora_rank=hidden, index_topk=4,
125+
))
126+
csa_hca = CSAHCAHybridV4(CSAHCAConfig(
127+
hidden_size=hidden, num_heads=H, head_dim=D, m_csa=2, m_hca=4,
128+
))
129+
130+
x = mx.random.normal((B, S, hidden))
131+
qr = mx.random.normal((B, S, hidden))
132+
cos, sin = _freqs(S, 4)
133+
134+
out_with = np.array(apply_indexer_to_csa_hca(
135+
indexer, csa_hca, x, qr, (cos, sin),
136+
k_super_csa=1, k_super_hca=1,
137+
))
138+
out_without = np.array(csa_hca(x)) # no select_indices → all-super
139+
# Restricting attention to 1 super-token per query should change the output.
140+
assert not np.allclose(out_with, out_without, atol=1e-6)

0 commit comments

Comments
 (0)