Repository navigation
Sparse decode attention for long contexts (on by default), Qwen3 14B split kernel, Gemma tokenizer fixes (1.4.0) - #10
Merged
Conversation
…piled) Training-free sparse decode attention after Latent Sparse Attention (arXiv 2610.42147), reduced to what pays at this engine's context sizes: every 4 keys of a full-attention layer get a pooled f16 index key; a decode query scores the blocks between the sink and the recent window (sum over heads, one selection for every head), keeps the exact top-p blocks and attends to sink + kept keys + window. No clustering, tree search or trained projections. Prefill and verification stay dense. Off unless MLXR_SPARSE_ATTN is set; the default code paths are unchanged. It changes greedy output, so it cannot ship by default under the project's correctness gates. - Kernels: sparse_index, sparse_score, sparse_select (radix select + stable compaction), attention_sparse (attention_split over a position list; bit-identical to it with the full list). - Transformer: init(directory:contextLength:sparse:), index maintenance after qkv_post, sparse branch of decode attention, `sparse` / `sparseAvailable`; the memory check counts the index. - mlxr bench: MLXR_BENCH_CTX=8192,30000 adds decode after long prompts, dense and sparse, with greedy and teacher-forced agreement (MLXR_BENCH_TEXT for a real-text prompt). - Tests: plan, index, scores, selection (ties, odd thread counts), attention over a list; full-budget sparse decode bit-identical to split decode on the installed models. Written without a Swift toolchain or Metal GPU: not compiled, run or benchmarked. See docs/sparse-decode-attention.md for the checklist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Px3ESajhJTwjR33meWGur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Px3ESajhJTwjR33meWGur
- KV-shared layers use their source's index only when its KV heads and head size match their own. - sparse_select assigns each thread's run of blocks in (simdgroup, lane) order, the order of its prefix sums. - A draft model always loads dense; the memory check counts the index for the target only. - Doc comment: a full budget is bit-identical to split decode. - docs: review and selection-simulation status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Px3ESajhJTwjR33meWGur
Compiled and ran on an M2 Pro (16 GB) with no source changes: the sparse unit tests, the full-budget bit-identical decode on 4 models and the full suite (161 tests, MLXR_SPARSE_ATTN unset) pass; start-up is unchanged. Dense vs sparse decode on Qwen3 0.6B and Mistral 7B at 8K/16K/30K with a real text prompt: 1.11-2.39x faster, teacher-forced agreement 89-100% at the default budget, 95% at 4096+1024 keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…panel plan Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Qwen3 14B (40 query / 8 KV heads, head size 128) had no attention_split instantiation, so long-context decode used the single-threadgroup kernel and MLXR_SPARSE_ATTN did nothing on it. Adds G = 5 and 8 for head size 128 (Qwen3 and Qwen2.5 14B / 32B). Split decode stays bit-identical to the single kernel (splitDecodeAttentionIsBitIdentical now covers Qwen3 14B), and so does sparse decode with a full budget. mlxr bench, long-context section (MLXR_BENCH_CTX): - teacher-forced on the text after the prompt (MLXR_BENCH_POSITIONS, default 512): KL(dense||sparse), NLL of the text under both, top-1 agreement by dense decode's top-1 margin; sparse runs first and dense rewrites each position, so the cache stays dense decode's - a dense control for the argmax agreement on dense decode's tokens - MLXR_BENCH_TEXT_OFFSETS: prompts from several places in the text - five passkey needles at 10-90% depth, recalled dense and sparse - the GPU cost of a dense verification pass of T = 1, 2, 5, 8 tokens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The index query summed q . k over every head, so one list served all KV heads and a head's important keys could lose to the others'. Now sparse_score writes one score row per KV head (the sum over that head's query heads), sparse_select runs one threadgroup per row, and attention_sparse reads its KV head's list. Attention reads the same bytes; the per-head score maps each KV head to its own lanes, so it costs the same as the shared score (kernel timing at 30K: 216 vs 226 us per layer on Qwen3 0.6B shapes). MLXR_SPARSE_SHARED=1 keeps the shared selection. Qwen3 0.6B, Pride and Prejudice, 512 teacher-forced positions, two regions of the book (shared -> per-head): - 16K: KL 0.035 -> 0.004 and 0.012 -> 0.005 nats; top-1 91.4 -> 96.1%, 93.4 -> 96.5% - 30K: KL 0.033 -> 0.010 and 0.046 -> 0.013 nats; text NLL delta +0.049 -> +0.016 and +0.053 -> +0.012 nats/token; top-1 88.9 -> 93.9%, 88.3 -> 92.2% Decode speed unchanged (30K: 105.7 -> 104.2 tok/s, 16K 130.8 -> 128.2). Tests: per-head and shared scores against a CPU reference (including one lane per KV head and a KV head count that does not divide 32), independent selection rows, per-head lists in attention_sparse, and full-budget decode bit-identical to dense in both modes on every installed model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Loaded next to the Gemma suite's models it pushes a 16 GB Mac into swap (the full suite stalled). Its split, sparse and prefill checks pass with MLXR_TEST_LARGE=1 swift test --filter <test>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Records the stronger bench, the Qwen3 14B split kernel (+20% / +27% dense decode at 8K / 16K, bit-identical), per-head selection (better on Qwen3 0.6B and 8B, worse on Mistral 7B at 30K), the rejected kernel experiments with their numbers, the cost of a verification pass (T = 5 is 3.1-3.6x T = 1), and the re-bench of Qwen3 0.6B / 8B / 14B and Mistral 7B at 8K-30K. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…c caveats attentionOverTheList covers the new G = 5 and 8 instantiations (Qwen3 14B / 32B shapes) in the default suite, now that the 14B model tests need MLXR_TEST_LARGE=1. The doc notes the run-to-run exceptions, that the Mistral shared-vs-per-head comparison is a single run, that the larger models have probably memorised the bench text, and the verification cost as measured (3.1-3.6x) rather than as a full re-read per token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- On by default from 5,128 tokens of context (below that decode stays dense and outputs are unchanged). MLXR_SPARSE_ATTN=0 turns it off. - App: the chat inspector's Long context section (Sparse attention, Long-range keys, Key selection), applied at the next load like the context length; the Model section shows the layers that run sparse. - CLI: mlxr run / serve --sparse <on|off|N>, and a status line on load. - InferenceEngine.load(..., sparse:); when the index does not fit in memory the model loads with dense decode (LoadedModelInfo.sparseNote says why). - Every architecture: the full-budget bit-identity test now covers Qwen2.5, Gemma 1.1, 2 (soft-capped), 3 and 4 (KV-shared layers) besides Qwen3, Llama, Phi and Mistral (Gemma 4 12B and Qwen3 14B with MLXR_TEST_LARGE=1). Note: this changes greedy output past 5,128 tokens on the default path, an exception to AGENTS.md's token-identical gate made at the owner's request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…B split kernel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mlx-lm 0.32.2 / mlx 0.32.4 on the same M2 Pro, model files and text: decode at 8K-30K (dense; its quantized KV cache at 30K) and prefill, beside our dense and sparse decode. Sparse decode is level at 8K on 7-14B models and 1.4-2.4x faster at 30K; our dense decode is 3-11% behind on 7-14B models; prefill is 1.4-2x slower at 30K. The README's limitations say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s one Without it Gemma's agreement metrics were meaningless (Gemma 1.1 text NLL 15.8 nats, Gemma 4 E2B 5.8-6.2); Gemma 4's tokenizer does not ask for BOS, so the tokenizer's add_bos_token alone is not enough. Speed is unaffected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gemma 4's tokenizer files neither set add_bos_token nor add BOS in their
post-processor, so raw completions (mlxr run --raw, /v1/completions) and the
long-context bench fed it prompts without BOS, and it produced nonsense. A chat
template that writes {{ bos_token }} now marks the model as expecting BOS on
raw prompts too. Chat was unaffected (the template writes BOS itself).
Tests: the template rule, and which installed models start raw prompts with
BOS (Gemma 1.1-4, Llama, Mistral) or not (Qwen3, Phi-3.5).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Prefill is always dense, including a one-token tail (a regenerate's last prompt token ran sparse, so its first token could differ from a fresh run). forward() with several tokens is dense; a single token is a decode step. - Speculative decoding is dense throughout: the no-draft single step used to run sparse while verification was dense, so output depended on which drafts were accepted. Its output now equals dense decoding. - MLXR_SPARSE_ATTN: off / false / no (and anything not understood) turn it off; only an unset value, 1 / on / true / yes or a key budget turn it on. --sparse keeps MLXR_SPARSE_WINDOW / _MIN / _SHARED (SparseAttention.configured). - A sparse setup failure (pipeline, allocation, thread limit) leaves the model with dense decode instead of failing the load. No index is allocated when the context cannot reach minContext, and the memory check follows. - sparseLayerCount counts KV-shared layers that read their source's index. - The template BOS rule does not override an explicit add_bos_token: false. - G = 8 split / sparse kernels removed: attention_split_4_8 was not bit-identical to attention_4_32 (new kernel test). G = 5 (Qwen3 14B) is. - AGENTS.md states the sparse attention exception to the token-identical gate. - Tests: environment parsing; split vs single-threadgroup decode bitwise for head sizes up to 128, including G = 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rder
Two tokenizer bugs found by comparing whole-book tokenizations with the
reference (transformers) tokenizer:
- JSONSerialization drops a U+FEFF that starts a string, raw or escaped.
Gemma lists "\u{FEFF}\r" next to "\r", so that entry took over "\r":
every carriage return was encoded as id 141385 instead of 235316 on Gemma
1.1 (text NLL 17 nats on a CRLF book; the reference tokenization gives the
same model far lower). U+FEFF in tokenizer.json is now swapped for a
private-use stand-in the file does not contain and mapped back after
parsing. Files without U+FEFF are untouched (one cheap scan).
- BPE merged every occurrence of the best pair per pass (GPT-2's reference
code). Hugging Face merges one pair at a time, lowest rank and leftmost
first, so a pair the merge creates competes at once. They agree when the
merge list is in training order; Gemma's is not (whitespace-run merges
rank early), and 32 spaces came out as 16 + 16 instead of 31 + one joining
the next word. mergePairs now follows Hugging Face with a (rank, position)
heap, O(n log n).
Whole-book tokenization now matches the reference on Gemma 1.1, 2, 3 and 4,
Qwen3, Qwen2.5, Llama 3.2, Mistral and Phi-3.5. The inspector caption no
longer says shared selection suits Mistral (that came from prompts without BOS).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Accuracy for Gemma 1.1-4, Llama 3.2, Phi-3.5 and Qwen2.5 beside their speeds (1.06-3.0x). Gemma 4 E2B is the outlier (KL 0.07-0.19, +8% perplexity at 30K) and Gemma 1.1 a lesser one; both are said so. - Mistral rows and the per-head/shared comparison redone with BOS: per-head is level or better on every model compared. The earlier Mistral reversal came from prompts without BOS. - Needle recall: one fewer than dense on Gemma 3 1B at 8K and 16K. - CHANGELOG: the Gemma tokenizer fixes and BOS on Gemma 4 raw prompts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Speculative generations set the model's sparse mode to nil for their duration, so every step is dense: the prompt forward with a one-token suffix ran sparse, and prepared next-step buffers (encoded sparse) were thrown away on every no-draft step (lost CPU/GPU overlap). The per-call dense flag on that step is gone; prompt forwards are always dense. - Transformer.sparseOffReason says why a model asked for sparse attention decodes dense (context below minContext, no eligible layer, or a setup failure on this GPU); the engine's note and mlxr bench show it instead of "no layer of this model supports it". - The memory check takes the SparseAttention and counts the index only when the clamped context reaches minContext; a load checks once and retries without the index only when that fails. - tokenizer.json BOM shielding is one pass that tracks JSON strings: only a U+FEFF that starts a string (raw, or escaped in any case) is replaced, and the stand-in is a private-use scalar the file does not use. Pre-tokenizer patterns and the post-processor's BOS are mapped back too. - One macro instantiates each split shape's attention_split and attention_sparse, so the two lists cannot drift. Whole-book tokenization still matches the reference on all 9 tokenizers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🚀 Deploying Preview to Cloudflare 🚀Preview Deployments by commit
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Sparse decode attention for long contexts, on by default from 5,128 tokens. Also a faster dense decode for Qwen3 14B, and three Gemma fixes (tokenizer and BOS) found while measuring.
Sparse decode attention
What it does. Each full-attention layer keeps a mean key for every block of 4 keys. Each KV head scores those blocks against its query heads. It then attends to:
Prefill, speculative decoding and draft models stay dense. The design is training-free, based on Latent Sparse Attention (arXiv 2610.42147).
Coverage. Every architecture: Qwen3, Qwen2.5, Llama, Mistral, Phi-3 and Gemma 1–4. Sliding-window layers stay dense.
Speed on an M2 Pro (4-bit, 16 GB), against our own dense decode:
For example, at 30K:
Against mlx-lm 0.32.2 (dense) it is level at 8K on 7–14B models and 1.4–2.4× faster at 30K.
It changes output past 5,128 tokens, which is an explicit exception to the token-identical gate (AGENTS.md is updated). Quality was measured over 512 teacher-forced positions of real text, with KL, text NLL and margin-bucketed agreement:
Settings:
mlxr run/mlxr serve --sparse <on|off|N>.MLXR_SPARSE_ATTN(0/offdisables it),MLXR_SPARSE_WINDOW,MLXR_SPARSE_MIN,MLXR_SPARSE_SHARED.Fallbacks. If the index does not fit in memory, the model loads dense, and so it does if sparse setup fails on the GPU. The engine reports the reason.
Qwen3 14B dense decode, 20–27% faster
Its 5 query heads per KV head had no split-attention kernel. With one, 14B decodes at 17.5 tok/s instead of 14.6 at 8K, and 14.0 instead of 11.0 at 16K. Output is bit-identical. G = 8 (the 32B models) was left out because its split kernel was not bit-identical.
Gemma fixes
Whole-book tokenization now matches transformers on Gemma 1.1–4, Qwen3, Qwen2.5, Llama 3.2, Mistral and Phi-3.5.
JSONSerializationdrops a U+FEFF that starts a string, so Gemma's U+FEFF + CR vocabulary entry replaced the one for CR. Every CR got the wrong token.--raw,/v1/completions) now start with BOS. Its tokenizer files never ask for it.Bench and tests
mlxr benchwithMLXR_BENCH_CTXreports, besides dense and sparse speed:MLXR_KERNEL_TIMING=1times the sparse kernels.Tests:
swift test: 170 tests in 29 suites pass. The Qwen3 14B and Gemma 4 12B cases needMLXR_TEST_LARGE=1.Reviews
Two adversarial code reviews ran on the branch, and every validated finding is fixed. The fixes include:
MLXR_SPARSE_ATTN=offand unrecognised values turn it off;Not fixed: the memory estimate still counts index bytes for head shapes without a sparse kernel. No supported model has one.
Known limitations
These are in
docs/sparse-decode-attention.md:Reference:
docs/sparse-decode-attention.md. Released as 1.4.0 (Version.swiftand the CHANGELOG are bumped).🤖 Generated with Claude Code