Repository navigation
Conversation
Tensor-parallel DeepSeek V4 attention interleaved heads across ranks inside every output group, so wo_a produced a partial sum of the full grouped low-rank output and wo_b had to be replicated after an all_sum. Every rank read the whole wo_b (~36 MB per layer, ~1.5 GB per token on Flash) on every decode step. When o_groups divides by the world size, give each rank whole output groups instead: a contiguous block of heads. wq_b and wo_a are then plain row splits, wo_a's local output is exactly the matching column block of wo_b's input, and wo_b is split on its input dim. A single all_sum of the hidden state (4096 instead of 8192 values) still finishes the layer, and no output projection is replicated. Other world sizes keep the interleaved layout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XSvpsPpNrx5ZY24MwZ9xtd
BatchGenerator.step ran agree_on_tasks on every step whenever its queue was empty. The agreement is a blocking all_gather that is queued behind the decode step already submitted to the GPU, so each token waited for the next step to finish before the next graph could be built, serializing graph construction with GPU work. While a generation is running, agree every 16 steps instead (and still on every step when idle). The step count is identical on every rank, so the collectives stay in lockstep; a new request waits at most 16 decode steps before it is admitted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XSvpsPpNrx5ZY24MwZ9xtd
At decode, each DeepSeek V4 layer is a long chain of small dependent GPU kernels. Patches applied through apply_mlx_patches, all limited to cases where the result is unchanged: - MoE router: mlx_lm's fused gate kernel scored all experts and ran top-k in a single GPU thread per token. Use one thread per expert and TOP_K threadgroup argmax rounds; selections and weights match exactly. - Indexer: while the compressed pool has at most index_topk rows, top-k selects every row, so scoring and the attention-side gather only permute the keys attention sees. Skip them at decode; the Indexer's compressor still runs so its pool is ready once the context grows. - Fused kernels for hc_pre (cast + RMSNorm + mix matmul), hc_post, q/kv RMSNorm + partial RoPE, inverse RoPE, and sink-aware single-query SDPA. Norm/RoPE are bit-identical to mx.fast; hc_post and SDPA accumulate in fp32 and are closer to an fp64 reference than the mlx_lm path. - gather_qmm: pass cached lhs_indices so each expert projection no longer launches an arange kernel. Adds mx.fast type stubs and the DeepSeek V4 stub members these patches use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XSvpsPpNrx5ZY24MwZ9xtd
DeepSeek V4 checkpoints ship a multi-token-prediction block (mtp.0.*) that mlx-community conversions drop. Convert it separately (scripts/convert_deepseek_v4_mtp.py) and, when the converted weights are present in ~/.exo/mtp/<model>/, load it next to the model (tensor-parallel sharded like the main layers) and use it to draft two tokens per step. Each round verifies [current, draft1, draft2] in one forward and samples every position with the request's own sampler; a position's sample is used only while every earlier draft equals the sample before it, so the output distribution is exactly that of normal decoding. Rejected tokens are rolled back: the sliding window is sliced, and compressor branches either keep the window emitted on a kept token or go back to the pre-step snapshot and re-append the kept tokens' recorded inputs. Speculation applies to single-request batches without logits processors or top-k logprobs; anything that changes the batch flushes it first. Supporting changes: - decode kernels and the indexer shortcut handle up to 4 tokens per step (per-query window and compressed-row visibility in the attention kernel) - the verification graph is submitted once after 8 layers so the GPU starts while the CPU finishes building it - shard_deepseek_v4_block is factored out of the V4 sharding strategy so the MTP block is sharded the same way - drafting waits for the preceding normal step to finish: building the draft graph on its unevaluated hidden state intermittently produced NaN logits Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XSvpsPpNrx5ZY24MwZ9xtd
…1 s) - Prefill attention kernel that only visits each query's sliding window and visible (Indexer-selected) compressed rows, instead of a dense masked SDPA over the whole chunk plus a one-hot top-k mask. - Fused ReLU + weighted head sum for prefill Indexer scoring. - hc_post / hc_pre kernels at prefill lengths (many-row hc_mix kernel). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XSvpsPpNrx5ZY24MwZ9xtd
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.
Motivation
Long prompts on
mlx-community/DeepSeek-V4-Flashprefill slowly on a 4-node tensor-parallel (MlxJaccl) instance (4× Mac mini M5 Pro, Thunderbolt 5 RDMA). A 25.5K-token prompt took 112 s to the first token. The cost per 4096-token chunk grew from 11 s at the start of the prompt to 25 s at 20K tokens of context.Profiling one chunk found three causes:
S + windowraw keys and the whole compressed pool, then masks almost all of them away. Each query sees only 128 window keys plus at most 512 Indexer-selected rows.[S, S + window + pool]matmul.[S, top_k, pool]comparison. That is 13 G elements per layer at 25K context.[S, n_heads, pool]scores twice. The ReLU rewrites the whole tensor (2.7 GB at 20K) and the weighted head sum then reads it again. This runs replicated on every rank.hc_precasts to fp32, normalizes and matmuls.hc_postuses an fp32 matmul. Together they take about 1.2 s per chunk.Stacked on #2400 (which includes #2398).
Changes
patches/deepseek_v4_prefill_attention.py(new): sparse prefill attention.RotatingKVCache, uniform pool) and leaves other cases to the existing path.V4Attention.__call__is mlx_lm's code: projections, compressor, Indexer and output projection.deepseek_v4_indexer: fused ReLU + weighted head sum for prefill.deepseek_v4_decode_kernels: hc kernels at every length.hc_postuses the existing fused kernel at prefill sizes too (3.5× faster).hc_pregets a many-row mix kernel: two rows per threadgroup, so x is read once and fn is reused across rows (5.3 → 3.0 ms per call).Results
End to end, measured through the API with a single request (MTP on):
Decode speed is unchanged (59 tok/s, short prompt). Needle-in-a-haystack recall at 10%, 50% and 90% depth is 3/3 at every length.
Per 4096-token chunk at 20K tokens of context, standalone TP: 25.4 s before, 5.5 s after.
After, the time splits as follows: attention 3.35 s (including the Indexer's 1.25 s), MoE 1.6 s, hc_pre + hc_post 0.5 s.
Correctness
hc_post,hc_preand the ReLU-weighted sum: the new kernels match float64 references more closely than the paths they replace.Note: Indexer RoPE layout in mlx_lm
While testing I found that mlx_lm's
Indexer.__call__rotates its queries in[B, S, H, D]layout.mx.fast.ropetakes positions from the second-to-last axis, so headhis rotated to positionoffset + h. DeepSeek's reference rotates by token position.Its prefill top-k also ranks rows that complete later in the same chunk, which makes results depend on the chunk size.
I tried fixing both. KL vs the current behaviour was 0.005, but NLL did not improve measurably on three corpora. This PR keeps mlx_lm's Indexer semantics unchanged, and the issue is left for a separate discussion.
Tests
src/exo/worker/engines/mlx/tests/test_deepseek_v4_prefill.pyuses the tiny DeepSeek V4 from #2400. It covers:hc_mixvs float64.uv run basedpyright(0 errors),uv run ruff check,nix fmt, anduv run pytest(546 passed).🤖 Generated with Claude Code
https://claude.ai/code/session_01XSvpsPpNrx5ZY24MwZ9xtd