You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
DeepSeek-V4 attention runs over two KV streams per layer: a sliding-window local stream at full token resolution, and (on layers with a nonzero compress ratio) a pooled stream in which a compressor summarizes every r consecutive tokens into one pooled token (r is the layer's entry in compress_ratios, either 4 or 128). On the r == 4 layers the pooled prefix grows without bound, so the model does not attend to all of it: a per-layer indexer selects the index_topk most relevant pooled positions per query, and attention runs over the local window plus only the selected pooled tokens. HiSA (hierarchical indexing for sparse attention) is the selection algorithm: a coarse block-level filter followed by a fine within-block top-k, replacing a flat O(Np) scan over all pooled positions.
This issue covers the indexer end to end: the indexer module and its weights, the flat top-k baseline, both HiSA hierarchical paths (single-token decode and batched prefill), and the sparse attention combine that consumes the selected indices.
Dependency
Blocked by #523 (base DeepSeek-V4 backbone). The plan below targets the module layout #523 will introduce: once #523 lands, the model lives in src/models/deepseek_v4.rs with the following pieces this issue builds on:
The Compressor (gated pooling + RMSNorm + RoPE) and its pooled-stream cache. The indexer owns a private compressor instance, so the module must be reusable with a caller-chosen output head dim.
The per-layer attention dispatch on compress_ratios (0: local only, 128: local + dense pooled, 4: local + sparse pooled).
The dense local+pooled combine with attention sinks, which the sparse combine below generalizes.
Until this issue is done, r == 4 layers should fall back to dense attention over all pooled tokens. That fallback is numerically exact while the pooled prefix has at most index_topk entries and an approximation beyond that (the model is specified to attend sparsely there).
Current state in mlxcel
No DeepSeek-V4 module exists yet (#523 is open). The in-tree prior art is the DeepSeek-V3.2 sparse-attention indexer (added for #509):
src/models/deepseek_v32_indexer.rs: a per-layer indexer that scores every cached indexer key against the current query with relu(q @ k^T), combines heads with a learned per-head weight weights_proj(x) * n_heads^-0.5 * head_dim^-0.5, applies the additive causal mask, and takes a flat argpartition top-index_topk. Indexer::load (line 66) returns Ok(None) when the checkpoint has no indexer weights, and DENSE_FALLBACK_ENV (MLXCEL_DSA_DENSE, line 41) force-disables it for A/B runs. The scoring core is the pure function indexer_top_indices (line 199), factored out for unit testing.
src/models/deepseek_v32.rs: consumption patterns. Decode (L == 1) gathers the selected KV rows directly with take_along_axis (gather_topk_kv, line 497) instead of building a mask; prefill restricts scores with a sparse additive mask (apply_sparse_prefill_mask). Short-context parity: selection returns None when kv_len <= index_topk and the dense path runs unchanged.
src/models/glm_moe_dsa.rs: a second consumer of the same indexer, showing how the module is shared across architectures.
How the V4 indexer differs from that one:
It scores pooled compressed keys, not per-token keys. The keys come from the indexer's own compressor (its own pooled cache), so there is no wk/k_norm per-token key projection; there are compressor.{wkv,wgate,ape,norm} weights instead.
Selection is hierarchical (block means first, then within-block token scores), not one flat top-k, and it has distinct decode and batched formulations.
The selected indices address pooled positions, and the consuming attention combines a sliding-window local stream with the gathered pooled rows under a single split softmax with per-head sinks, instead of feeding one homogeneous KV stream.
What carries over: the relu-scored per-head weighted combine, argpartition top-k, take_along_axis gather, the Ok(None)-when-absent load contract, the env kill-switch pattern, and the "pure selector function + unit tests" factoring. All required ops already exist in mlxcel-core (argpartition, take_along_axis, relu, logsumexp_axis, logaddexp, minimum, maximum, clip), so no FFI additions are expected.
Algorithm
Conventions
All tensors are written with explicit axis order; axis 0 is batch. Activations are (B, L, hidden), attention tensors (B, H, L, D) with heads on axis 1.
Linear weights are stored (out_features, in_features); linear(x) = x @ W^T.
t0 is the cache offset: the number of tokens already consumed by this layer's local cache before the current step. Query row j in [0, L) has absolute token position t = t0 + j (0-based).
All selection scoring is computed in f32 regardless of model dtype.
argpartition top-k returns an unordered index set. Attention is order-invariant, so this is correct, but tests comparing selections must sort both sides.
Configuration
From the checkpoint config.json (values for DeepSeek-V4-Flash):
key
value
meaning
compress_ratios
list, entries in {0, 4, 128}
per-layer ratio; the checkpoint carries 44 entries for 43 layers, use the first num_hidden_layers entries
index_topk
512
pooled positions kept per query (k)
index_n_heads
64
indexer heads (H_i)
index_head_dim
128
indexer head dim (D_i)
sliding_window
128
local stream window
q_lora_rank
1024
rank of the shared query residual
compress_rope_theta
160000.0
RoPE base for pooled-stream layers
qk_rope_head_dim
64
rotated dims per head
Two keys are architectural defaults not present in the checkpoint config and must get serde defaults:
key
default
meaning
index_block
64
HiSA block size b, in pooled rows; 0 disables the hierarchy (flat scan only)
index_keep
16
HiSA blocks kept by the coarse stage (Kb upper bound)
Only r == 4 layers have an indexer. Checkpoint weight keys and logical shapes (the 4-bit checkpoint at /home/inureyes/models/deepseek-v4-flash-4bit stores linear weights quantized, U32-packed with scales; shapes below are dequantized logical shapes):
The 2 * D_i output width is the overlap compressor variant used at r == 4 (each pooled row also draws on the previous window; r == 128 layers use the plain variant with output width equal to the head dim, see #523).
The pooled stream and positions
The indexer's compressor turns the layer input x (B, L, hidden) into pooled indexer keys pooled (B, Np, D_i), appended to its own pooled cache. Pooled row p (axis 1, 0-based) is anchored at the token window [p*r, p*r + r - 1]; the overlap variant additionally draws on tokens [(p-1)*r, p*r - 1].
RoPE: pooled row p is rotated as absolute token position p * r; indexer queries are rotated at their absolute token positions t. Both use base compress_rope_theta = 160000 and rotate only a 64-dim subspace of the 128-dim head: with interleaved pairing (pair m = dims (2m, 2m+1), m in [0, 64)), pairs 0..31 (dims 0..63) are position-independent and pairs 32..63 (dims 64..127) rotate.
Causal visibility: the query at absolute position t may see pooled row p iff p < floor((t + 1) / r). Because visibility is a contiguous prefix of the pooled axis, it is fully described by a count: V(t) = min(Np, floor((t + 1) / r)). During single-token decode every cached pooled row satisfies this (the newest window closes at the current token), so decode needs no pooled mask.
The indexer query and per-head weights
Inputs shared with the main attention path: q_residual (B, L, 1024) = rms_norm(wq_a(x)) (the main attention's query LoRA bottleneck; the indexer must receive this tensor, not recompute it).
NEG is a large negative finite constant (dtype minimum or -1e30), never -inf: scores are relu-nonnegative, so any finite very-negative value sorts strictly last and argpartition never sees NaN.
HiSA decode path (L == 1)
Gate conditions, all required: L == 1, the pooled visibility mask is absent (always true for L == 1), index_block > 0, Np >= index_block * index_keep, and index_block * index_keep >= k (enough fine candidates to fill the top-k).
Let b = index_block, nb = floor(Np / b), Kb = min(index_keep, nb), C = Kb * b. Block beta in [0, nb) covers pooled rows [beta*b, (beta+1)*b - 1] on the pooled axis. The tail rows [nb*b, Np) are not candidates in this path; this is intended (they summarize the newest tokens, which the local window already covers at full resolution).
# coarse: score block-mean representatives
rep[b_,beta,d] = mean over rows p in block beta of pooled[b_,p,d] # (B, nb, D_i)
cs[b_,h,0,beta] = relu( sum_d q[b_,h,0,d] * rep[b_,beta,d] ) # (B, H_i, 1, nb)
cscore[b_,0,beta] = sum_h cs[b_,h,0,beta] * scale * w[b_,0,h] # (B, 1, nb)
top_blk = argpartition_desc(cscore, Kb, axis=-1)[..., :Kb] # (B, 1, Kb)
# fine: expand retained blocks to a candidate table and rescore exactly
pos[b_,0,c] = top_blk[b_,0, c // b] * b + (c % b) for c in [0, C) # (B, 1, C) absolute pooled row ids
cand[b_,c,d] = pooled[b_, pos[b_,0,c], d] # (B, C, D_i) via take_along_axis on axis 1
fs[b_,h,0,c] = relu( sum_d q[b_,h,0,d] * cand[b_,c,d] ) # (B, H_i, 1, C)
fscore[b_,0,c] = sum_h fs[b_,h,0,c] * scale * w[b_,0,h] # (B, 1, C)
sel = argpartition_desc(fscore, k, axis=-1)[..., :k] # (B, 1, k) indices into the candidate axis
I[b_,0,kk] = pos[b_,0, sel[b_,0,kk]] # (B, 1, k) absolute pooled row ids
No causal masking anywhere in this path: during decode all Np pooled rows are visible. Cost is O(nb + Kb*b) scores per step instead of O(Np).
HiSA batched path (L > 1, prefill and multi-token verify)
Gate conditions: L > 1, index_block > 0, Np >= index_block * index_keep, index_block * index_keep >= k. This path handles causality explicitly, so it runs with or without a pending visibility mask.
valid_len[b_,j] = V(t0 + j) = min(Np, floor((t0 + j + 1) / r)) # (B, L) i32, visible-prefix length per query row
# coarse (same rep/cscore math as decode, but per query row)
cscore[b_,j,beta] # (B, L, nb)
cscore[b_,j,beta] = -1e30 if beta * b >= valid_len[b_,j] # block eligible iff its FIRST row is visible
top_blk = argpartition_desc(cscore, Kb, axis=-1)[..., :Kb] # (B, L, Kb), i32
# fine, tiled over the query axis in chunks of fine_chunk = 512 rows
# (bounds the (B, chunk, C, D_i) candidate gather; memory stays flat in L)
for each chunk [s, e):
pos_c[b_,j,c] = top_blk[b_,s+j, c // b] * b + (c % b) # (B, e-s, C)
cand[b_,j,c,d] = pooled[b_, pos_c[b_,j,c], d] # (B, e-s, C, D_i), gather on the pooled axis
q_bl = transpose(q[:, :, s:e, :], (0, 2, 1, 3)) # (B, e-s, H_i, D_i): batch the matmul over (B, chunk)
fs[b_,j,h,c] = relu( sum_d q_bl[b_,j,h,d] * cand[b_,j,c,d] ) # (B, e-s, H_i, C)
oc[b_,j,c] = sum_h fs[b_,j,h,c] * scale * w[b_,s+j,h] # (B, e-s, C)
oc[b_,j,c] = -1e30 if pos_c[b_,j,c] >= valid_len[b_,s+j] # per-row causal mask (handles partially visible blocks)
fscore = concat(chunks, axis=1) # (B, L, C)
sel = argpartition_desc(fscore, k, axis=-1)[..., :k] # (B, L, k)
I[b_,j,kk] = pos[b_,j, sel[b_,j,kk]] # (B, L, k) absolute pooled row ids
Coarse eligibility uses only the block's first row; rows of a partially visible block are re-masked in the fine stage. Early query rows can have fewer than k visible rows; the top-k then contains masked ids, which is safe because the consuming attention re-masks them (below) so they get zero weight.
Consuming the selection: sparse combine
The attention branch on an r == 4 layer is three-way on the attention-side pooled length Np (the attention compressor and the indexer compressor pool with the same r, so their pooled axes are position-aligned):
Np > index_topk: sparse combine with I (B, L, k) from the indexer, k = index_topk:
# streams (Dh = 512 = head_dim, W = current local KV length, K = V in both streams)
q (B, 64, L, Dh), already roped; q_s = q * Dh^(-1/2)
local_kv (B, 1, W, Dh) # single shared KV head, broadcast over the 64 query heads
pooled_kv (B, Np, Dh) # the ATTENTION compressor's pooled stream, not the indexer's
# gather per-query pooled rows
P[b_,j,kk,d] = pooled_kv[b_, I[b_,j,kk], d] # (B, L, k, Dh)
local_scores = q_s @ local_kv^T # (B, 64, L, W), + sliding-window causal mask
pooled_scores = transpose(transpose(q_s,(0,2,1,3)) @ P^T, (0,2,1,3)) # (B, 64, L, k)
pooled_scores[b_,h,j,kk] = -inf if I[b_,j,kk] >= V(t0 + j) # re-mask gathered invisible rows (prefill only)
n = logaddexp(logsumexp(local_scores, -1), logsumexp(pooled_scores, -1)) # (B, 64, L, 1)
n = logaddexp(n, sink[h]) # per-head attention sink joins the normalizer
w_local = exp(local_scores - n) # sink absorbs mass, contributes no value
w_pooled = exp(pooled_scores - n)
out[b_,h,j,:] = w_local @ local_kv + w_pooled[b_,h,j,:] @ P[b_,j,:,:] # (B, 64, L, Dh)
This is one softmax over the union of both streams plus the sink, computed via logaddexp so the two score tensors never have to be concatenated per query.
Engagement thresholds (defaults, r = 4)
pooled rows Np
tokens (approx.)
path
Np <= 512 (index_topk)
<= ~2048
dense over pooled; selection output unused
512 < Np < 1024 (index_block * index_keep)
~2048 to ~4096
flat scan top-512
Np >= 1024
>= ~4096
HiSA hierarchy (decode or batched)
Operational requirement: the indexer's compressor must run and advance its pooled cache on every forward of an r == 4 layer, even while the dense branch is taken, otherwise the pooled keys do not exist when Np crosses index_topk. Only the scoring/selection may be skipped below the threshold.
Indexer::load(weights, args, attn_prefix) reading {attn_prefix}.indexer.*; return Ok(None) when the keys are absent, mirroring src/models/deepseek_v32_indexer.rs:66.
Pure selector functions with explicit shapes, factored for unit tests (the indexer_top_indices pattern at src/models/deepseek_v32_indexer.rs:199): flat_top_indices, hisa_select_decode, hisa_select_batched.
Env kill-switches following the MLXCEL_DSA_DENSE precedent: one that disables the indexer entirely (dense fallback) and one that forces the flat scan (A/B against the hierarchy).
Decode gather via take_along_axis (prior art gather_topk_kv, src/models/deepseek_v32.rs:497).
Config (ModelArgs for deepseek_v4): add index_block (serde default 64) and index_keep (serde default 16); confirm index_topk, index_n_heads, index_head_dim are parsed (they are present in the checkpoint config).
Tests: src/models/deepseek_v4_indexer_tests.rs (or #[cfg(test)] in the module), patterned on src/models/deepseek_v32_tests.rs: synthetic f32 weights, load round trip, selector assertions.
Validation and acceptance criteria
Selector unit tests (pure functions, synthetic tensors):
Flat top-k matches a brute-force sorted argsort selection (as sets) on random scores, with and without a visibility cutoff.
Hierarchy equivalence: plant score mass so the true top-k rows all lie inside the top-Kb blocks; assert hisa_select_decode and hisa_select_batched return the same index set as the flat scan (sort both sides).
Causality: for the batched path with a synthetic valid_len, every selected index is < valid_len[j] whenever at least k rows are visible for row j.
Tail exclusion: rows in [nb*b, Np) never appear in hierarchical selections.
Gate behavior: Np < index_block * index_keep or index_block == 0 falls back to the flat scan; Np <= index_topk on the attention side keeps the dense branch.
Parity vs a full-attention oracle at short context:
With a small synthetic config where k == Np (every row selected), assert the sparse combine (gather + split softmax + sink) reproduces dense local+pooled attention within fp tolerance. This is the end-to-end check of the logaddexp algebra.
Long prompt (>= ~4300 tokens so Np >= 1024 at r == 4): prefill (batched path) and decode (fast path) both engage, generation is coherent, no NaN/inf in logits.
Long-context sanity:
Retrieval probe: a fact planted early in a filler prompt long enough to cross the sparse threshold is still answered correctly.
Decode step time stays roughly flat as context grows (selection cost is O(nb + Kb*b), not O(Np)).
Summary
DeepSeek-V4 attention runs over two KV streams per layer: a sliding-window local stream at full token resolution, and (on layers with a nonzero compress ratio) a pooled stream in which a compressor summarizes every
rconsecutive tokens into one pooled token (ris the layer's entry incompress_ratios, either 4 or 128). On ther == 4layers the pooled prefix grows without bound, so the model does not attend to all of it: a per-layer indexer selects theindex_topkmost relevant pooled positions per query, and attention runs over the local window plus only the selected pooled tokens. HiSA (hierarchical indexing for sparse attention) is the selection algorithm: a coarse block-level filter followed by a fine within-block top-k, replacing a flat O(Np) scan over all pooled positions.This issue covers the indexer end to end: the indexer module and its weights, the flat top-k baseline, both HiSA hierarchical paths (single-token decode and batched prefill), and the sparse attention combine that consumes the selected indices.
Dependency
Blocked by #523 (base DeepSeek-V4 backbone). The plan below targets the module layout #523 will introduce: once #523 lands, the model lives in
src/models/deepseek_v4.rswith the following pieces this issue builds on:Compressor(gated pooling + RMSNorm + RoPE) and its pooled-stream cache. The indexer owns a private compressor instance, so the module must be reusable with a caller-chosen output head dim.compress_ratios(0: local only, 128: local + dense pooled, 4: local + sparse pooled).Until this issue is done,
r == 4layers should fall back to dense attention over all pooled tokens. That fallback is numerically exact while the pooled prefix has at mostindex_topkentries and an approximation beyond that (the model is specified to attend sparsely there).Current state in mlxcel
No DeepSeek-V4 module exists yet (#523 is open). The in-tree prior art is the DeepSeek-V3.2 sparse-attention indexer (added for #509):
src/models/deepseek_v32_indexer.rs: a per-layer indexer that scores every cached indexer key against the current query withrelu(q @ k^T), combines heads with a learned per-head weightweights_proj(x) * n_heads^-0.5 * head_dim^-0.5, applies the additive causal mask, and takes a flatargpartitiontop-index_topk.Indexer::load(line 66) returnsOk(None)when the checkpoint has no indexer weights, andDENSE_FALLBACK_ENV(MLXCEL_DSA_DENSE, line 41) force-disables it for A/B runs. The scoring core is the pure functionindexer_top_indices(line 199), factored out for unit testing.src/models/deepseek_v32.rs: consumption patterns. Decode (L == 1) gathers the selected KV rows directly withtake_along_axis(gather_topk_kv, line 497) instead of building a mask; prefill restricts scores with a sparse additive mask (apply_sparse_prefill_mask). Short-context parity: selection returnsNonewhenkv_len <= index_topkand the dense path runs unchanged.src/models/deepseek_v32_tests.rs: unit-test structure (synthetic weights,Indexer::loadround trip, selector assertions, short-contextNonebehavior).src/models/glm_moe_dsa.rs: a second consumer of the same indexer, showing how the module is shared across architectures.How the V4 indexer differs from that one:
wk/k_normper-token key projection; there arecompressor.{wkv,wgate,ape,norm}weights instead.What carries over: the relu-scored per-head weighted combine,
argpartitiontop-k,take_along_axisgather, theOk(None)-when-absent load contract, the env kill-switch pattern, and the "pure selector function + unit tests" factoring. All required ops already exist inmlxcel-core(argpartition,take_along_axis,relu,logsumexp_axis,logaddexp,minimum,maximum,clip), so no FFI additions are expected.Algorithm
Conventions
(B, L, hidden), attention tensors(B, H, L, D)with heads on axis 1.(out_features, in_features);linear(x) = x @ W^T.t0is the cache offset: the number of tokens already consumed by this layer's local cache before the current step. Query rowj in [0, L)has absolute token positiont = t0 + j(0-based).argpartitiontop-k returns an unordered index set. Attention is order-invariant, so this is correct, but tests comparing selections must sort both sides.Configuration
From the checkpoint
config.json(values for DeepSeek-V4-Flash):compress_ratiosnum_hidden_layersentriesindex_topkindex_n_headsindex_head_dimsliding_windowq_lora_rankcompress_rope_thetaqk_rope_head_dimTwo keys are architectural defaults not present in the checkpoint config and must get serde defaults:
index_blockindex_keepOnly
r == 4layers have an indexer. Checkpoint weight keys and logical shapes (the 4-bit checkpoint at/home/inureyes/models/deepseek-v4-flash-4bitstores linear weights quantized, U32-packed with scales; shapes below are dequantized logical shapes):The
2 * D_ioutput width is the overlap compressor variant used atr == 4(each pooled row also draws on the previous window;r == 128layers use the plain variant with output width equal to the head dim, see #523).The pooled stream and positions
x (B, L, hidden)into pooled indexer keyspooled (B, Np, D_i), appended to its own pooled cache. Pooled rowp(axis 1, 0-based) is anchored at the token window[p*r, p*r + r - 1]; the overlap variant additionally draws on tokens[(p-1)*r, p*r - 1].pis rotated as absolute token positionp * r; indexer queries are rotated at their absolute token positionst. Both use basecompress_rope_theta = 160000and rotate only a 64-dim subspace of the 128-dim head: with interleaved pairing (pairm= dims(2m, 2m+1),m in [0, 64)), pairs 0..31 (dims 0..63) are position-independent and pairs 32..63 (dims 64..127) rotate.tmay see pooled rowpiffp < floor((t + 1) / r). Because visibility is a contiguous prefix of the pooled axis, it is fully described by a count:V(t) = min(Np, floor((t + 1) / r)). During single-token decode every cached pooled row satisfies this (the newest window closes at the current token), so decode needs no pooled mask.The indexer query and per-head weights
Inputs shared with the main attention path:
q_residual (B, L, 1024) = rms_norm(wq_a(x))(the main attention's query LoRA bottleneck; the indexer must receive this tensor, not recompute it).Let
scale = D_i^(-1/2). Every path below combines heads the same way: per-head relu score, timesscale, timesw[.., j, h], summed over the head axis.Flat selection (baseline path)
Used when the hierarchy is disabled (
index_block == 0) or the prefix is too short for it (thresholds below). Withk = min(index_topk, Np):NEGis a large negative finite constant (dtype minimum or -1e30), never -inf: scores are relu-nonnegative, so any finite very-negative value sorts strictly last andargpartitionnever sees NaN.HiSA decode path (L == 1)
Gate conditions, all required:
L == 1, the pooled visibility mask is absent (always true for L == 1),index_block > 0,Np >= index_block * index_keep, andindex_block * index_keep >= k(enough fine candidates to fill the top-k).Let
b = index_block,nb = floor(Np / b),Kb = min(index_keep, nb),C = Kb * b. Blockbeta in [0, nb)covers pooled rows[beta*b, (beta+1)*b - 1]on the pooled axis. The tail rows[nb*b, Np)are not candidates in this path; this is intended (they summarize the newest tokens, which the local window already covers at full resolution).No causal masking anywhere in this path: during decode all
Nppooled rows are visible. Cost isO(nb + Kb*b)scores per step instead ofO(Np).HiSA batched path (L > 1, prefill and multi-token verify)
Gate conditions:
L > 1,index_block > 0,Np >= index_block * index_keep,index_block * index_keep >= k. This path handles causality explicitly, so it runs with or without a pending visibility mask.Coarse eligibility uses only the block's first row; rows of a partially visible block are re-masked in the fine stage. Early query rows can have fewer than
kvisible rows; the top-k then contains masked ids, which is safe because the consuming attention re-masks them (below) so they get zero weight.Consuming the selection: sparse combine
The attention branch on an
r == 4layer is three-way on the attention-side pooled lengthNp(the attention compressor and the indexer compressor pool with the samer, so their pooled axes are position-aligned):Np == 0: local-only attention (as feat(models): port DeepSeek-V4 (MLA + group-limited MoE backbone) #523).0 < Np <= index_topk: dense over local + all pooled rows (as feat(models): port DeepSeek-V4 (MLA + group-limited MoE backbone) #523); byte-identical to what sparse selection would produce, since every row would be selected.Np > index_topk: sparse combine withI (B, L, k)from the indexer,k = index_topk:This is one softmax over the union of both streams plus the sink, computed via logaddexp so the two score tensors never have to be concatenated per query.
Engagement thresholds (defaults, r = 4)
index_topk)index_block * index_keep)Operational requirement: the indexer's compressor must run and advance its pooled cache on every forward of an
r == 4layer, even while the dense branch is taken, otherwise the pooled keys do not exist when Np crossesindex_topk. Only the scoring/selection may be skipped below the threshold.Implementation plan
src/models/deepseek_v4_indexer.rs(new)Indexerstruct:wq_b,weights_proj(UnifiedLinear), a privateCompressorinstance (from feat(models): port DeepSeek-V4 (MLA + group-limited MoE backbone) #523, output head dimindex_head_dim), andn_heads,head_dim,index_topk,index_block,index_keep,scale.Indexer::load(weights, args, attn_prefix)reading{attn_prefix}.indexer.*; returnOk(None)when the keys are absent, mirroringsrc/models/deepseek_v32_indexer.rs:66.indexer_top_indicespattern atsrc/models/deepseek_v32_indexer.rs:199):flat_top_indices,hisa_select_decode,hisa_select_batched.MLXCEL_DSA_DENSEprecedent: one that disables the indexer entirely (dense fallback) and one that forces the flat scan (A/B against the hierarchy).src/models/deepseek_v4.rs(once feat(models): port DeepSeek-V4 (MLA + group-limited MoE backbone) #523 lands)r == 4layers a third cache slot for the indexer's pooled stream, next to the local cache and the attention pooled cache.take_along_axis(prior artgather_topk_kv,src/models/deepseek_v32.rs:497).ModelArgsfor deepseek_v4): addindex_block(serde default 64) andindex_keep(serde default 16); confirmindex_topk,index_n_heads,index_head_dimare parsed (they are present in the checkpoint config).src/models/deepseek_v4_indexer_tests.rs(or#[cfg(test)]in the module), patterned onsrc/models/deepseek_v32_tests.rs: synthetic f32 weights, load round trip, selector assertions.Validation and acceptance criteria
Selector unit tests (pure functions, synthetic tensors):
Kbblocks; asserthisa_select_decodeandhisa_select_batchedreturn the same index set as the flat scan (sort both sides).valid_len, every selected index is< valid_len[j]whenever at leastkrows are visible for rowj.[nb*b, Np)never appear in hierarchical selections.Np < index_block * index_keeporindex_block == 0falls back to the flat scan;Np <= index_topkon the attention side keeps the dense branch.Parity vs a full-attention oracle at short context:
Np <= index_topk, assert the sparse-capable layer produces bit-identical output to feat(models): port DeepSeek-V4 (MLA + group-limited MoE backbone) #523's dense pooled path (branch selection, structural).k == Np(every row selected), assert the sparse combine (gather + split softmax + sink) reproduces dense local+pooled attention within fp tolerance. This is the end-to-end check of the logaddexp algebra.Real-checkpoint smoke (
/home/inureyes/models/deepseek-v4-flash-4bit):Np >= 1024atr == 4): prefill (batched path) and decode (fast path) both engage, generation is coherent, no NaN/inf in logits.Long-context sanity:
O(nb + Kb*b), notO(Np)).Out of scope