Repository navigation
feat: add DeepSeek-V4 (Pro/Flash) model support - #1189
machiabeli wants to merge 32 commits into
Conversation
Implements model_type deepseek_v4 with all V4 architecture features: - Manifold-constrained Hyper-Connections (mHC) with Sinkhorn-Knopp normalization replacing residual connections - Hash-routed MoE gate for first num_hash_layers layers - sqrtsoftplus scoring function - Sliding-window + compressed KV attention - FP8 e4m3 block dequant (128x128, ue8m0 scales) - Compressor / Indexer (params loaded, topk dispatch in v0.2) - Pipeline + distributed sharding support Tested: prefill + decode with cache, hash routing, mHC Sinkhorn doubly-stochastic verification, V4-Flash config (43L/256E/6-of-256). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Checkpoint uses layers.N (no model. prefix), embed.weight/head.weight,
gate.bias (not e_score_correction_bias), .scale suffix (not _scale_inv)
- Add proper FP8 e4m3 block dequant matching HF weight format
- Drop MTP weights, remap hc_{attn,ffn}_{fn,base,scale} -> hc_{attn,ffn}.{fn,base,scale}
- Remap shared_experts.w{1,2,3} -> {gate,down,up}_proj
- Tested with mock checkpoint key structure matching HF model.safetensors.index.json
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
I tested this against the DeepSeek-V4-Flash config and full checkpoint, then pushed the support branch here: https://github.com/Thump604/mlx-lm/tree/deepseek-v4-support-fixes I also opened the same changes as a draft PR against the contributor branch so the review diff stays on top of this PR: machiabeli#1 What I changed on top of this PR:
Validation so far: PYTHONPATH=/Users/David/code/mlx-lm-deepseek-v4-review \
/opt/ai-runtime/venv-live/bin/python -m pytest -q tests/test_models.py tests/test_tokenizers.py
# 86 passed, 1 skipped, 5 warnings, 57 subtests passedFull checkpoint conversion now completes for DeepSeek-V4-Flash with this local recipe: mlx_lm.convert \
--hf-path /Volumes/Lexar/hf-staging/deepseek-ai/DeepSeek-V4-Flash \
--mlx-path /Volumes/Lexar/mlx_models/DeepSeek-V4-Flash-MLX-Q3-mixed-gs128-affine \
--quantize \
--quant-predicate mixed_3_6 \
--q-group-size 128 \
--q-mode affine \
--trust-remote-code
# reported 3.808 bits per weightThe converted artifact lazy-loads cleanly in MLX. I would still keep this gated before merge on sparse compressed attention and learned indexer parity. The current path can convert and load the real Flash checkpoint, but it is not yet a complete correctness claim against the reference implementation. |
|
Awesome work guys! Additionally, I would like to note that there are missing tests in test_models.py |
|
In general, look at deepeseek v3 implementation in this repo for inspiration around dequantization, predicates, and rope 👌🏽 |
Two correctness bugs that bypass single-batch BF16 inference but break quantized models and any batched call: 1. wo_a grouped low-rank projection used `self.wo_a.weight.reshape(...)` directly. After quantization, .weight is packed int4 with shape (out, in/8); the reshape silently produced wrong dims. Now dequant when `hasattr(self.wo_a, "scales")` before reshape. 2. MoEGate hash branch returned `inds`/`weights` flattened to (B*S, top_k) while the non-hash branch returns (B, S, top_k). SwitchGLU's broadcast happened to work at B=1; it failed at B>1 with "Shapes (2,2,1) and (4,2) cannot be broadcast". Now reshape both back to match x.shape[:-1] (mirrors the non-hash branch). Smoke-tested live on DeepSeek-V4-Flash-4bit: load 27s, gen 11 tok/s, peak RAM 160 GB on M3 Ultra single-node. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors test_deepseek_v3 with V4-specific dims: - mHC, hash MoE (num_hash_layers=2 of 4), o_groups split, MTP off. Caught the B>1 hash-routing bug fixed in the previous commit; smoke testing at B=1 hid it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… model_types transformers >= 5.5 standardises RoPE in PreTrainedConfig.from_dict; if the model_type is not registered (e.g., a freshly added arch like deepseek_v4) it falls back to bare PreTrainedConfig and chokes on self.rope_parameters / self.max_position_embeddings during RoPE standardisation. AutoTokenizer.from_pretrained surfaces this as ValueError / AttributeError before any tokenizer load happens. Wrap AutoTokenizer in try/except. On failure load tokenizer.json directly via PreTrainedTokenizerFast and pull bos/eos/pad/unk + chat_template + model_max_length from tokenizer_config.json. Special tokens stored as dicts get wrapped in AddedToken. Unblocks tokenizer load for any new arch ahead of transformers registering it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mHC's Sinkhorn-normalized comb matrix is computed twice per layer per token (hc_attn + hc_ffn) — for V4-Flash that's 86 calls of softmax + 20 alternating row/col-norms per generated token, all as separate MLX ops. ~40 kernel launches per call dominated runtime. Replace with a single fully-unrolled register-resident Metal kernel: - One thread per token; each thread owns its hc^2 matrix in registers (16 floats for V4-Flash hc=4 — well under register budget). - Softmax + add-eps + initial col-norm + (iters-1) × (row-norm, col-norm) all fused; reads input once, writes output once. - Kernel is generated per (hc, iters) at first call and cached. eps is baked at compile time. - Falls back to the Python reference for hc > 8 or when Metal is unavailable. Microbenchmarks (M3 Ultra, hc=4, iters=20): N= 64 ref 0.85ms kernel 0.24ms 3.5x N= 1024 ref 0.96ms kernel 0.23ms 4.2x N= 4096 ref 0.97ms kernel 0.28ms 3.5x N=16384 ref 1.51ms kernel 0.26ms 5.7x End-to-end on DeepSeek-V4-Flash-4bit (240B, 4-bit, single M3 Ultra): Before: 11.07 tok/s After: 20.21 tok/s (1.83x) Numerical agreement with reference: max|kernel - ref| = 2.4e-7 (within fp32 epsilon at iters=20). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
I pushed the follow-up branch updates here: https://github.com/Thump604/mlx-lm/tree/deepseek-v4-support-fixes New commits since my last note:
Validation on the branch: Conversion evidence from the official
I still treat full sparse compressed-attention/indexer parity as unproven. The branch now has conversion, lazy-load, and Q2 smoke-generation evidence, but not a production-quality or long-context claim. |
Incorporates checkpoint-validated fixes from @Thump604 (David) tested against the real DeepSeek-V4-Flash release. His 9 commits on top of d7eb43d, resolved preferring his implementations where both branches touched the same code (wo_a quant, tokenizer fallback — his per-group mx.quantized_matmul is faster than our dequant+reshape). Improvements landed: - DeepseekV4RoPE with inverse output rotation for post-attention rope dims - attn_sink integrated into SDPA (with dtype cast fix) - F8_E8M0 block scale decode for native FP8 loading - Routed expert FP4 unpacking - Ratio-4 Compressor shapes validated against real checkpoint - mixed_3_6 quant predicate preserving attn/compressor/indexer higher precision - DeepSeek V4 E8M0 scale metadata loader - Final norm.weight key mapping - Tokenizer fallback (per-PR improvement over our earlier version) Our Sinkhorn Metal kernel (6cc5c24) will be re-applied as a follow-up commit. Co-Authored-By: David (Thump604) <noreply@github.com>
45665f8 to
8cbd0a7
Compare
The Compressor in PR ml-explore#1189 was prefill-only MVP: at decode time no state was accumulated, so streaming inference past the first chunk could not produce compressed KV rows. This blocks the V4 architecture's long-range memory path for any non-one-shot generation. Ports the full prefill + decode state machine from DeepSeek's PyTorch reference (inference/model.py:316-377): * Prefill: pool S//ratio rows in one shot as before, then seed the decode state buffers so subsequent streaming continues without discontinuity. For overlap layers (ratio=4) the last `ratio` tokens of the cutoff region are pre-loaded into state[:ratio]; any sub-ratio remainder is loaded into state[offset:offset+remainder]. * Decode: on each token write (kv_t, score_t) to the correct slot of state; when (start_pos + 1) % ratio == 0, emit one compressed row. Overlap layers concat-half-and-half across state[:ratio] and state[ratio:], then rotate state[:ratio] <- state[ratio:] for the next window. Non-overlap layers use the full state buffer directly. * RoPE: Compressor now owns a DeepseekV4RoPE configured for compress_rope_theta (+ YaRN if present). Applied at strided positions [0, r, 2r, ...] for prefill and at (start_pos+1-r) for each decode emission. Matches reference lines 364-366. Decode state is kept in a plain dict (self._decode_state) rather than as mx.array attributes, so MLX nn.Module does not auto-register it as a learnable parameter. Skipped (per port plan non-goals): Hadamard rotation (rotate_activation) and FP4/FP8 activation quantization. These are perf optimizations; the stored representation is fp32 here. Tests: * test_deepseek_v4_compressor_prefill_decode_parity_ratio4: 16 tokens prefill vs 16 single-token decode steps produce identical rows within fp32 tolerance (max abs diff < 1e-4). This is the foundational parity check — if any state math is off, it fails. * test_deepseek_v4_compressor_prefill_decode_parity_ratio128: same, 256 tokens, non-overlap path, 2 emitted rows. No wiring into V4Attention yet — that's Phase 3. All 11 existing V4 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
I validated the current PR head after it pulled in my support branch: PYTHONPATH=/Users/David/code/mlx-lm-pr1189 \
/opt/ai-runtime/venv-live/bin/python -m pytest -q tests/test_models.py tests/test_tokenizers.pyResult: I do not have another local patch to push right now. The branch now has the important unit coverage I wanted to see for V4 shape handling, RoPE inverse, FP4/FP8/E8M0 loading, mixed quantization predicate, and quantized grouped output projection. My remaining caveat is the same claim boundary as before: this is strong conversion/load/unit-test evidence, but I would still avoid framing it as complete long-context or production-quality sparse compressed-attention/indexer parity until that path is compared against the reference implementation. I also would not treat the uploaded Q2/Q3 artifacts as local 128 GB runtime candidates; they are useful conversion artifacts and support evidence, not a quality-qualified serving lane for that class of machine. |
Implement the Indexer forward pass that was deferred in v0.1. For ratio-4 layers, the indexer now scores all compressed KV rows via a lightweight compressor (index_head_dim=128) and selects the topk rows (512 for Flash, 1024 for Pro). Selected rows are gathered and prepended to the sliding-window KV before SDPA, reducing per-layer attention from O(S/4) to O(topk) — a 500x reduction at 1M context. Also: - Replace mx.einsum with direct matmul (@) in hc_post for the comb @ residual broadcast multiply. Avoids einsum overhead on the hot decode path (86x/token). - Fix Sinkhorn Metal kernel address space: use base+offset indexing instead of pointer variables to avoid constant-vs-device cast failure on small inputs. 8/8 DeepSeek-V4 tests pass including new test_deepseek_v4_indexer_topk which validates indexer output shape, index bounds, and full model forward + decode with 32-token prefill exercising the compressed attention path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
I validated the new /opt/ai-runtime/venv-live/bin/python -m pytest -q tests/test_models.py tests/test_tokenizers.py -k 'deepseek_v4 or deepseek_unknown'
# 8 passed, 81 deselected, 4 warningsThe new test coverage exercises the indexer shape/bounds, full forward on the compressed attention path, and prefill to decode cache transition. The claim boundary still looks right to me: this is now stronger unit-level coverage for compressed sparse attention prefill/top-k selection, but not yet a full production parity claim because the incremental compressor decode path is still deferred. No additional local patch from me right now. I still would not recommend treating the uploaded Q2/Q3 artifacts as 128 GB Mac runtime candidates. They remain useful conversion artifacts and support evidence for this PR, not a local serving lane for that class of machine. |
|
Thanks @Blaizzy — Writing the test caught a real bug the smoke test hid: Also looking at V3's quant/predicate/rope patterns per your suggestion — @Thump604 has a draft PR against this branch (machiabeli#1) that adds |
|
Merged @Thump604's draft PR (machiabeli#1) into this branch at 45665f8. Landed:
Plus our earlier perf work stays on top:
Test matrix now at 6 V4 tests, all passing: Thump604 has already uploaded validated Q2/Q3 mixed artifacts to HF; I've uploaded a (now outdated) Q4 at |
v0.2: Indexer topk for compressed sparse attentionPushed What changed: For ratio-4 layers (20 of 43 in Flash, 29 of 61 in Pro), the indexer now runs a lightweight scoring pass over all compressed KV rows (using its own compressor at
Selected rows are gathered and prepended to the sliding-window KV before SDPA with a zero-mask prefix (compressed rows are always "past"). This reduces per-layer attention cost from O(S/4) to O(topk) — roughly 500x at 1M context. Also in this commit:
Test coverage: New
Live generation validated: DeepSeek-V4-Flash-4bit on single M3 Ultra (512GB). What is next (v0.3):
|
|
@PhilipJohnBasile Thanks for consolidating this and for keeping the limitations explicit. I inspected draft PR machiabeli#10 at The consolidation is a good stabilization vehicle, but it should not yet be treated as evidence that #1189’s advertised feature set is complete. Please reconcile this PR’s summary/checklist with the draft’s narrower contract before promotion: qualify cached decode as the supported single-stream target path; do not claim source-faithful top-k sparse attention; and explicitly call out unsupported generic fresh-cache batching, prompt-cache serialization, DSpark speculation, real-checkpoint generation, long-context quality, and performance. Where an unsupported batching or cache path can still be entered, I would prefer an explicit fail-closed capability guard rather than relying only on a known-limitations paragraph. Also, the draft currently has no GitHub checks or recorded reviews, so the “independent final review” is not verifiable from the PR. Please attach that evidence or have the reviewer submit it formally once the diff is frozen. Keeping #10 as a draft until those scope claims and gates are reconciled is the right posture. |
|
Thanks for the concrete review. I pushed the revised consolidation draft at The draft body now matches the narrower contract: one stream, initial prefill, then single-token cached decode. It does not claim source-faithful top-k sparse attention, generic batching, prompt-cache serialization, DSpark, real-checkpoint generation, long-context quality, or performance. The unsupported overlapping-insertion path now fails closed before scheduler/cache mutation. The final regression matrix includes compressed-only, realistic mixed An independent Codex Sol-max reviewer approved the complete frozen diff after earlier passes rejected several concrete cache-lifecycle defects. I have described that as independent AI review, not formal GitHub review, and asked @Thump604 to submit a formal review on #10 if the final scope and gates are acceptable. #10 remains a stabilization vehicle, not evidence that #1189's advertised feature set is complete. Please reconcile this PR's summary/checklist with that narrower contract before promotion. |
|
Independent external review only: I am not a maintainer or collaborator on ml-explore/mlx-lm, and this is offered as review assistance rather than project approval. I reviewed the frozen stabilization draft machiabeli#10 at b759c7f. Within its explicitly narrow contract—one stream, initial prefill, then single-token cached decode—I found no blocking defect. Its non-batchable capability preflight rejects overlapping insertion before scheduler queues or active cache state are mutated; the singleton cache lifecycle and sequential reuse are covered. My local verification passed all 18 newly added focused regressions (plus 3 subtests), the 22-test prompt-cache suite, and git diff --check. That result does not validate this PR as currently advertised. #1189 remains conflicting and its summary/checklist still claims a broader feature set than #10 qualifies, including general cached decode and model support while source-faithful indexer behavior, generic batching, DSpark speculation, real-checkpoint generation, long-context quality, and performance remain unsupported or unverified. Recommendation: hold #1189 until the stabilization work is integrated or rebased, the PR body/checklist is narrowed to the proven contract, conflicts are resolved, and project CI/reviewer evidence is present. |
Repro: progressive corruption starting at ~99 prompt tokens (boundary at
|
| prompt tokens | result |
|---|---|
16 (17×23) |
✅ 391 |
| ~67 | ✅ The maintenance password is PELICAN-7734. |
| ~99 | ❌ PELIC-7731 — password garbled, sentence structure intact |
| ~131 | ❌ echoes filler text |
| ~163 … ~6100 | ❌ fully degenerate repetition loops |
The corruption is progressive, not a cliff — at 99 tokens the model still finds the needle but retrieves it corrupted. The onset sits right at sliding_window: 128, far below index_topk: 512, so the indexer/top-k path is not involved.
Ruled out
- Checkpoint / weights — the same-source
Vontra/DeepSeek-V4-Flash-0731-MXFP4-MLXbuild run through its bundled standalone runtime retrieves the needle perfectly at ~800 prompt tokens (PELICAN-7734.). Weights family is healthy. - Quantization — consistent with @anerjy's 04-28 finding that the long-prompt failure is quant-blind (bf16 fails identically).
- Repetitive-filler artifact — repro'd with fully diverse filler sentences; same degeneration.
- Prefill chunking —
prefill_step_size32/64 changes the output slightly but does not fix it. - Sharding — single-node and 2-node TP degrade identically, so it's not
shard().
Side note: head does not import on Python 3.12
MTPBlock (line ~1104) uses Any in a class-body annotation without importing it → NameError: name 'Any' is not defined at module import on 3.12 (lazy annotations on 3.14 mask this). One-line fix: add Any to the typing import.
Question
@aidiffuser — since it works for you at 256k on the same hardware and the same head: which checkpoint (and Python version) are you running? Given the ladder above we'd expect any run through mlx_lm on 3.12 with an mxfp4 community checkpoint to break at ~100 tokens, so whatever differs between our setups should pinpoint the bug.
Happy to run any diagnostic build/patch against this ladder — it takes minutes on our side.
Co-authored with Claude (Anthropic).
|
@stepnoy — great ladder, and the answer to your question turns out to matter a lot: we are not running current head. Our fork predates the recent rewrite (no For completeness: Python 3.13.12, That said, we went looking in head with our working implementation as an oracle, and the ladder is explained by one line. The compressed/global branch is masked OFF during every prefill
if mask is not None:
comp_shape = list(mask.shape)
comp_shape[-1] = n_comp
comp_mask = mx.zeros(comp_shape, dtype=mask.dtype) # <-- here
mask = mx.concatenate([comp_mask, mask], axis=-1)
Verified on our machine (mlx 0.32.0.dev, Metal): create_causal_mask(99, 0, window_size=128).dtype # mlx.core.bool
mx.zeros((99, 24), dtype=mx.bool_) # all False
# SDPA bool semantics, 3 keys, one row each:
# mask [True, False, False] -> output == v[0]
# mask [False, False, True] -> output == v[2]And end-to-end: building head's exact mask for S=99, then scaling the pool keys by 50×, changes the attention output by There's a second half that makes it worse: at decode This reproduces your ladder exactly. With the global branch dead, a query at position
It also explains your "ruled out" list: it's independent of weights, of quantization, and of sharding, and The fix is a real visibility mask instead of a constant. Compressed row Two more in the same branch, likely latent behind the firstWhile the pool is blocked in prefill these can't show up, so they're worth checking right after the mask fix:
Send a patch and we'll validate it against a needle ladder on our side — 2× M3 Ultra, single-node and 2-node TP, minutes per run. We can also run your exact ladder against head on our own checkpoint, which would confirm the repro is checkpoint-independent from our end. Co-authored with Claude Fable 5 (Anthropic). |
|
@aidiffuser — your diagnosis was exact, thank you. Patched head accordingly; three of your items are confirmed fixed by the ladder, one gap remains, localized by a probe. Diff below; validation runs welcome. What the patch does (in dependency order)
Results (needle ladder,
|
| prompt tokens | before | after |
|---|---|---|
| ~267 / ~467 / ~866 / ~1567 | ❌ degenerate | ✅ verbatim |
| ~4101 | ❌ | ✅ verbatim |
| ~6133 | ❌ |
Decode throughput unchanged (~30–32 tok/s). The reference standalone runtime (Vontra) retrieves the same 6133-token needle, so the remaining gap is implementation, not model capacity.
The remaining gap, localized
Instrumented Indexer.__call__ on the 6133 case: the needle's pool row (765 of 1533) is absent from the final query's top-512 in 0 of 42 indexer invocations. With ~512/1533 per-layer inclusion, random noise would miss all 42 with p ≈ 4e-8 — the ranking systematically down-ranks it, while at ≤4101 tokens (pool ≤ 1025) retrieval survives. Things already replicated from the reference: ReLU-before-weighting, no sigmoid, folded index_head_dim^-0.5 * n_heads^-0.5 scale, sum over heads, fp32 scoring, causal mask before argpartition. Not replicated: the Hadamard rotate_activation + fp4 activation-sim pair (orthogonal-invariant for the dot product, so it should only matter through quantization noise) — and whatever else your working indexer does differently.
Could you share your indexer scoring path (or diff it against head's)? That's the one remaining piece between head and a clean ladder, and your fork predates the rewrite so the divergence should be easy to spot on your side.
Full diff vs head @ 63a2662 (208 lines)
--- mlx_lm/models/deepseek_v4.py.orig 2026-08-12 19:02:31
+++ mlx_lm/models/deepseek_v4.py 2026-08-13 11:07:47
@@ -238,6 +238,26 @@
@property
def inv_freq(self):
return self._inv_freq[0]
+
+ def apply_positions(self, x: mx.array, positions: mx.array):
+ """Rotate the last self.dims dims of x at explicit absolute positions.
+
+ x: [..., T, D]; positions: [T] (float or int). Interleaved-pair
+ convention identical to the array-offset branch of __call__.
+ """
+ dtype = x.dtype
+ theta = positions.astype(mx.float32)[:, None] * self.inv_freq[None, :]
+ shape = (1,) * (x.ndim - 2) + (x.shape[-2], self.dims // 2)
+ cos = mx.cos(theta).reshape(shape).astype(dtype)
+ sin = mx.sin(theta).reshape(shape).astype(dtype)
+ rot = x[..., : self.dims].reshape(*x.shape[:-1], self.dims // 2, 2)
+ x0, x1 = rot[..., 0], rot[..., 1]
+ rotated = mx.stack(
+ [x0 * cos - x1 * sin, x0 * sin + x1 * cos], axis=-1
+ ).reshape(*x.shape[:-1], self.dims)
+ if self.dims < x.shape[-1]:
+ return mx.concatenate([rotated, x[..., self.dims :]], axis=-1)
+ return rotated
def __call__(self, x: mx.array, offset: int = 0, inverse: bool = False):
dtype = x.dtype
@@ -819,10 +839,16 @@
# attention rotation to the wrong base on compressed layers, manifesting
# as periodic token drops in CJK (cf. Shinka-Man's report on #1192,
# fixed there in @Blaizzy/mlx-lm@b78ccb1).
- self.rope = DeepseekV4RoPE(self.rope_head_dim, args.rope_theta, args.rope_scaling)
- self.compress_rope = DeepseekV4RoPE(
- self.rope_head_dim, args.compress_rope_theta, args.rope_scaling,
- )
+ # Reference semantics: dense (ratio==0) layers use plain RoPE with
+ # rope_theta and YaRN disabled; compress layers use YaRN with
+ # compress_rope_theta for q/k/output *and* the pooled rows.
+ if self.compress_ratio:
+ self.rope = DeepseekV4RoPE(
+ self.rope_head_dim, args.compress_rope_theta, args.rope_scaling,
+ )
+ else:
+ self.rope = DeepseekV4RoPE(self.rope_head_dim, args.rope_theta, None)
+ self.compress_rope = self.rope
# Compressor / Indexer — present only when compress_ratio > 0
if self.compress_ratio:
@@ -901,6 +927,8 @@
# --- Compressed sparse attention ---
compressed_k = compressed_v = None
+ pool_topk_idx = None
+ pool_member_mask = None
if self.compress_ratio:
comp_cache = cache if isinstance(cache, CompressedKVCache) else None
if comp_cache is not None:
@@ -912,15 +940,43 @@
pool = None
if pool is not None:
+ # Pool rows carry no positional rotation in the cache; rotate
+ # row j at its window-start position j*ratio (rope dims are the
+ # last rope_head_dim of each row, as in the raw K path).
+ n_pool = pool.shape[1]
+ pool_pos = mx.arange(n_pool, dtype=mx.float32) * self.compress_ratio
+ rd_ = self.rope_head_dim
+ pool = mx.concatenate(
+ [pool[..., :-rd_],
+ self.rope.apply_positions(pool[..., -rd_:], pool_pos)],
+ axis=-1,
+ )
ckv = pool
if hasattr(self, "indexer") and ckv.shape[1] > self.args.index_topk:
- topk_idx = self.indexer(x, qr)
- if topk_idx is not None:
+ topk_idx = self.indexer(x, qr, offset=offset, rope=self.rope)
+ if topk_idx is not None and S == 1:
+ # Decode: a single query — gather its top-k rows so the
+ # softmax is not diluted over the full pool (reference
+ # restricts to index_topk at decode too).
+ sel = topk_idx[:, 0, :] # [B, k]
idx = mx.broadcast_to(
- topk_idx[:, :, None],
- (B, topk_idx.shape[1], self.head_dim),
+ sel[:, :, None], (B, sel.shape[1], ckv.shape[-1])
)
ckv = mx.take_along_axis(ckv, idx, axis=1)
+ elif topk_idx is not None and S > 1:
+ # Keep the full pool; restrict attention per query via
+ # a membership mask (visibility AND top-k), built in
+ # the mask branch below. Gathering one shared row set
+ # for the whole prompt drops mid-prompt rows.
+ member = mx.put_along_axis(
+ mx.zeros(
+ (B, S, ckv.shape[1]), dtype=mx.bool_
+ ),
+ topk_idx.astype(mx.int64),
+ mx.ones(topk_idx.shape, dtype=mx.bool_),
+ axis=-1,
+ )
+ pool_member_mask = member
compressed_k = ckv[:, None, :, :]
compressed_v = compressed_k
@@ -934,9 +990,36 @@
v = mx.concatenate([compressed_v, v], axis=2)
n_comp = compressed_k.shape[2]
if mask is not None:
+ # Visibility mask for pool rows. Row j pools raw tokens
+ # [j*ratio, (j+1)*ratio - 1], so it is visible to a query at
+ # absolute position p iff (j+1)*ratio <= p + 1. A constant
+ # zeros() here is all-False for boolean masks and silently
+ # disables the entire global branch during prefill.
+ ratio = self.compress_ratio
+ q_pos = offset + mx.arange(S) # [S]
+ j_idx = mx.arange(n_comp) # [n_comp]
+ visible = (j_idx[None, :] + 1) * ratio <= q_pos[:, None] + 1
+ if pool_member_mask is not None:
+ visible = mx.logical_and(
+ visible[None, :, :], pool_member_mask
+ )
+ # Broadcast to the mask's leading dims, then match dtype
+ # semantics: boolean -> keep; additive -> 0 / -inf.
comp_shape = list(mask.shape)
comp_shape[-1] = n_comp
- comp_mask = mx.zeros(comp_shape, dtype=mask.dtype)
+ while visible.ndim > len(comp_shape) and visible.shape[0] == 1:
+ visible = visible.squeeze(0)
+ while visible.ndim < len(comp_shape):
+ visible = mx.expand_dims(visible, axis=-3)
+ visible = mx.broadcast_to(visible, comp_shape)
+ if mask.dtype == mx.bool_:
+ comp_mask = visible
+ else:
+ comp_mask = mx.where(
+ visible,
+ mx.zeros(comp_shape, dtype=mask.dtype),
+ mx.full(comp_shape, float("-inf"), dtype=mask.dtype),
+ )
mask = mx.concatenate([comp_mask, mask], axis=-1)
out = scaled_dot_product_attention(
@@ -990,6 +1073,8 @@
self,
x: mx.array,
q_intermediate: mx.array,
+ offset: int = 0,
+ rope=None,
) -> Optional[mx.array]:
"""Score compressed rows and return topk indices.
@@ -1012,16 +1097,46 @@
q = q.reshape(B, S, self.n_heads, self.head_dim)
q = q.transpose(0, 2, 1, 3)
- scores = (q @ ck[:, None].transpose(0, 1, 3, 2)) * self.scale
-
- hw = mx.sigmoid(self.weights_proj(x))
- hw = hw.transpose(0, 2, 1)[..., None]
- scores = scores * hw
+ ratio = self.compressor.ratio
+ if rope is not None:
+ # Reference binds the layer rope to the indexer and its compressor:
+ # queries rotate at their absolute positions, pooled rows at their
+ # window starts j*ratio. Rotary dims are the LAST rope.dims —
+ # __call__ rotates the first dims of its input, so pass the slice.
+ q = mx.concatenate(
+ [q[..., : -rope.dims], rope(q[..., -rope.dims :], offset=offset)],
+ axis=-1,
+ )
+ row_pos = mx.arange(n_compressed, dtype=mx.float32) * ratio
+ ck = mx.concatenate(
+ [ck[..., : -rope.dims],
+ rope.apply_positions(ck[..., -rope.dims :], row_pos)],
+ axis=-1,
+ )
- agg = scores.sum(axis=2).mean(axis=1)
+ # Reference scoring: raw ReLU(q . ck) weighted by weights_proj output
+ # WITHOUT sigmoid, with the softmax scale folded into the weights, and
+ # summed (not averaged) over heads. fp32 to keep the ranking stable.
+ scores = mx.maximum(
+ (q.astype(mx.float32) @ ck[:, None].transpose(0, 1, 3, 2).astype(mx.float32)),
+ 0.0,
+ )
+ hw = self.weights_proj(x).astype(mx.float32) * (
+ self.scale * self.n_heads ** -0.5
+ )
+ hw = hw.transpose(0, 2, 1)[..., None]
+ agg = (scores * hw).sum(axis=1) # [B, S, n]
+ # Causality: row j is only scoreable by queries at absolute position
+ # p with (j+1)*ratio <= p+1; forbid the rest before ranking.
+ q_pos = offset + mx.arange(S)
+ vis = (mx.arange(n_compressed)[None, :] + 1) * ratio <= q_pos[:, None] + 1
+ agg = mx.where(
+ vis[None, :, :], agg, mx.full(agg.shape, float("-inf"), dtype=agg.dtype)
+ )
+
topk = min(self.index_topk, n_compressed)
- return mx.argpartition(-agg, kth=topk - 1, axis=-1)[:, :topk]
+ return mx.argpartition(-agg, kth=topk - 1, axis=-1)[..., :topk]
# --------------------------------------------------------------------------- #Co-authored with Claude (Anthropic).
|
@stepnoy — excellent turnaround, and your patch matches our semantics on every point you listed. Here is our indexer scoring path verbatim, then the one structural difference that I think explains the remaining gap. Our
|
|
@aidiffuser — your theory was right, and the discriminating check settled it in one run. Thank you; that saved us from digging further into the scoring path, where the bug was not. The checkInstrumented both numbers at every indexer invocation, 6042-token prompt, stock Exactly as you predicted. On the final prefill chunk the indexer can only emit indices That also explains the ≤4101 survivals: those runs put the needle in the last chunk. FixThree changes, in the order they were needed:
Ladder after the fix — Flash, single M3 Ultra, greedy
Ceiling not found — 190K is simply where I stopped. Long-form generation is clean too: worst of three runs on different prompts, 1% 4-gram repetition, no degeneration. The fix is not configuration-specific — confirmed on V4-Pro-0813You asked (reasonably) whether patches tuned on Flash would transfer. They do. I ran the same code, unmodified, on DeepSeek-V4-Pro-0813 — a rather different shape:
Note the third row in particular: Pro has no dense ( Probe across a full run: 205 indexer invocations, 0 mismatches between Two things others may hit on Pro-08131. It ships no from encoding_dsv4 import encode_messages
prompt = encode_messages(msgs, thinking_mode="chat") # "chat" | "thinking"
# <|begin▁of▁sentence|>…<|User|>…<|Assistant|>Feeding it through 2. Loading 792 GiB trips the Metal watchdog. It reads like OOM but isn't — it dies at 398–409 GiB against a 448 GiB model, config = load_model(path, lazy=True, strict=False)
model.shard(mx.distributed.init())
for layer in model.model.layers:
mx.eval(layer.parameters())With that, Pro loads in ~220 s at 404.5 GiB per rank. Might be worth doing inside Remaining divergence, and your offer
And yes — please do queue the run against your own checkpoint, single node and 2-node TP. Independent confirmation on a checkpoint I cannot see would be worth more than anything further I can produce here. Patch is against Co-authored with Claude (Anthropic). |
|
Hit this PR debugging DeepSeek-V4-Flash locally and independently reproduced the same failures on Two areas that look orthogonal to the generation work, in case they help:
Happy to open a PR (this branch, or |
|
Thanks for the writeup. Yes, please open the PR — both pieces are real on our side too. I re-checked them today against our patched branch (M3 Ultra 512 GB, mlx-lm 0.31.3 / mlx 0.32.0). No weights needed; a 64-token prefill into a
On mixed-quant we hit it from the other direction. If it helps, we can run your branch on our two 512 GB M3 Ultras and send back traces or numbers. I can also share the repro script — it builds a tiny |
|
Here are the two repro scripts, in case they save you the setup. Neither loads a checkpoint — the first builds a tiny
|
|
Found three deviations from the reference implementation while testing this 1. rope/YaRN assignment. The reference if self.compress_ratio:
original_seq_len, rope_theta = args.original_seq_len, args.compress_rope_theta
else:
# disable YaRN and use base rope_theta in pure sliding-window attention
original_seq_len, rope_theta = 0, args.rope_thetai.e. compressed layers (41 of 43) rotate main Q/K with Fix (one conditional): self.rope = DeepseekV4RoPE(
self.rope_head_dim,
args.compress_rope_theta if self.compress_ratio else args.rope_theta,
args.rope_scaling if self.compress_ratio else None,
)After this, greedy generation over 4.9K- and 19K-token documents is coherent 2. Compressed-pool visibility during prefill. The prefill path prepends 3. Pool rows are never rotated. The reference applies compress-theta rope There are a few further indexer-path deviations (per-query masked top-k vs a |
|
Following up on item 3 (pool rows never rotated) — I've now patched and The fix, mirroring the reference Compressor: after the RMSNorm, rotate the pos = (base_row + mx.arange(P)) * ratio # block-start positions
theta = pos[:, None] * rope.inv_freq[None, :]
# rotate ckv[..., -rope_head_dim:] with cos/sin of theta (interleaved pairs)(Equivalently, a rope instance with its frequencies pre-scaled by Result on the same 19K-token document task where I previously saw occasional |
|
Thanks everyone for the work here. |
Sinkhorn normalization for DeepSeek-V4 multi-Hyper-Connection (mHC) gating, with a fused Metal kernel combining pre/post-sigmoid, comb scaling+softmax, and the Sinkhorn iterations into one dispatch (hc_mult=4 fast path). Groomed from ml-explore#1189 (machiabeli:feat/deepseek-v4).
HyperConnection + HyperHead layers used by DeepSeek-V4, building on the Sinkhorn gating. Groomed from ml-explore#1189 (machiabeli:feat/deepseek-v4).
The DeepSeek-V4 (Pro/Flash) model: Compressed Sparse Attention (indexer topk), CompressedKVCache, MoE with Hyper-Connections, RoPE, attention sinks, and Multi-Token-Prediction heads. Inert until wired into conversion/loading/generation (subsequent commits). Groomed from ml-explore#1189 (machiabeli:feat/deepseek-v4).
Fall back to a generic PreTrainedConfig when Transformers does not recognize the model config (e.g. deepseek_v4 / max_position_embeddings), so V4 checkpoints load. Adds a regression test. Groomed from ml-explore#1189 (machiabeli:feat/deepseek-v4).
DeepSeek-V4 MLX conversion support and E8M0 scale-metadata loading so quantized (FP4/FP8) V4 checkpoints convert and load. Groomed from ml-explore#1189 (machiabeli:feat/deepseek-v4).
Wire MTP speculative decoding into the generation loop and server; auto-disable when MTP weights are absent from the checkpoint. Groomed from ml-explore#1189 (machiabeli:feat/deepseek-v4).
DeepSeek-V4 tests: FP4/FP8 dequant, E8M0 scales, RoPE derivation, quantized output projection, indexer/CSA topk, CompressedKVCache. Groomed from ml-explore#1189 (machiabeli:feat/deepseek-v4).
Self-contained model file for DeepSeek-V4-Flash: MLA attention with a compressed-sparse KV path and indexer top-k, hash-routed and bias-corrected softmax MoE, manifold-constrained Hyper-Connections (Sinkhorn-projected mHC), and dual-theta partial RoPE. Loads community MLX checkpoints (FP8/FP4 dequant, grouped output projection, pre-stacked wo_a) and drops the multi-token- prediction heads, which base inference does not consume. The compressed-attention and indexer correctness was informed by issues raised by @avlp12 and @stepnoy on ml-explore#1189.
Self-contained model file for DeepSeek-V4-Flash: MLA attention with a compressed-sparse KV path and indexer top-k, hash-routed and bias-corrected softmax MoE, manifold-constrained Hyper-Connections (Sinkhorn-projected mHC), and dual-theta partial RoPE. Loads community MLX checkpoints (FP8/FP4 dequant, grouped output projection, pre-stacked wo_a) and drops the multi-token- prediction heads, which base inference does not consume. The compressed-attention and indexer correctness was informed by issues raised by @avlp12 and @stepnoy on ml-explore#1189.
Self-contained model file for DeepSeek-V4-Flash: MLA attention with a compressed-sparse KV path and indexer top-k, hash-routed and bias-corrected softmax MoE, manifold-constrained Hyper-Connections (Sinkhorn-projected mHC), and dual-theta partial RoPE. Loads community MLX checkpoints (FP8/FP4 dequant, grouped output projection, pre-stacked wo_a) and drops the multi-token- prediction heads, which base inference does not consume. The compressed-attention and indexer correctness was informed by issues raised by @avlp12 and @stepnoy on ml-explore#1189.
Self-contained model file for DeepSeek-V4-Flash: MLA attention with a compressed-sparse KV path and indexer top-k, hash-routed and bias-corrected softmax MoE, manifold-constrained Hyper-Connections (Sinkhorn-projected mHC), and dual-theta partial RoPE. Loads community MLX checkpoints (FP8/FP4 dequant, grouped output projection, pre-stacked wo_a) and drops the multi-token- prediction heads, which base inference does not consume. The compressed-attention and indexer correctness was informed by issues raised by @avlp12 and @stepnoy on ml-explore#1189.
Summary
Adds
model_type: deepseek_v4support for DeepSeek-V4-Pro (1.6T/49B active) and DeepSeek-V4-Flash (284B/13B active), released April 22, 2026.V4-novel architecture features implemented:
hc_mult=4). Each block reduces via learnedpreweights, applies its sub-layer, then expands viapost+ doubly-stochasticcombmatrix (20-iteration Sinkhorn-Knopp normalization on the Birkhoff polytope). Pure-MLX implementation; Metal kernel follow-up planned.num_hash_layers(3) layers use a deterministictid2eidtable instead of learned gating, for stable early-layer routing.sqrtsoftplusscoring —sqrt(softplus(x))expert scoring function (new in V4).compress_ratiosarray (0 = pure window, 4 = light, 128 = heavy).Compressormodule with learned gated pooling + APE.Indexerparams loaded (topk sparse dispatch planned for v0.2).mx.from_fp8.HyperHead— final sigmoid-weighted reduction fromhc_multcopies to 1 beforelm_head.PipelineMixin.What works now (v0.1):
config.json✓Planned for v0.2:
attn_sinkintegration via SDPAsinks=kwargMemory estimates (V4-Flash):
Test plan
ModelArgs.from_dict(config)with real V4-Flash configsanitize()correctly remaps all 69K checkpoint keys🤖 Generated with Claude Code