feat: add DeepSeek-V4 (Pro/Flash) model support - #1189
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):
|
|
Hi — I've been validating this PR end-to-end against the Pre-fix: everything ≥ ~210 tokens collapses into pathological repetition. Threshold lines up exactly with Root cause (sourced from ds4 — see
I've opened 5 atomic draft PRs against
Verification with all 5 applied: 7/7 prompts pass against the ds4 oracle, spanning 5 tokens → 3500 tokens. The most striking case: at ~1960 tokens, post-fix mlx-lm produces Each PR has its own commit, comment trail, and test plan; machiabeli#5 and machiabeli#6 must land together to fix the ≥128-token collapse (they're logically coupled but touch different code paths). Happy to add unit tests, rebase, or restructure however helps the merge — let me know. |
The fused Sinkhorn Metal kernel in mlx_lm/models/sinkhorn.py produces incorrect results: the hidden-state std explodes across DeepSeek-V4's 43 layers and the resulting `comb` matrix diverges from the pure-MLX reference (PR ml-explore#1189 review thread). Gate the kernel behind `MLX_LM_HC_SINKHORN_KERNEL=1` so the default path is the verified MLX fallback; the kernel code is preserved for future re-validation. Add tests/test_sinkhorn.py covering output shapes and the doubly-stochastic property of `comb`, so any silent default flip is caught by CI. Verified locally: - tests/test_sinkhorn.py: 2/2 pass - tests/test_models.py V4 tests: 8/8 pass, 1 skipped (torch missing) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…erge/extend PR ml-explore#1189 review reported "request ml-explore#2 reads stale pool rows" in batched serving. Root cause: ``CompressedKVCache.merge``/``extend`` zero-padded mismatched ``_pool``/``_buf`` lengths before concatenating along the batch axis. Subsequent decode steps appended new tokens at the end of the now-padded rows, interleaving padding zeros with real tokens; the indexer/sparse-attention then picked the padded slots, silently corrupting output for every request after the first. The genuinely-correct fix is a full per-row length-tracking refactor of ``_pool``/``_buf`` (so the decode-time compression trigger respects per-sequence state). That requires a batched-server e2e test harness we don't have yet. As an interim production-safe mitigation, refuse to merge/extend mismatched caches: raise ``NotImplementedError`` with a diagnostic naming the offending field (pool_lens / buf_lens / buf_counts) instead of silently corrupting decode output. The synchronized-state path is preserved unchanged. tests/test_compressed_kv_cache.py covers all four mismatch axes (pool length, buf length, buf_count, None-vs-set) on both ``merge`` and ``extend`` (10 cases). Existing tests/test_models.py V4 tests still pass (9/9, 1 skipped for missing torch). Verification against numerical oracle (antirez/ds4) pending GGUF download completion -- this commit makes the failure mode loud, not silent; numerical equivalence in the synchronized path is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mx.fast.scaled_dot_product_attention with ``sinks=`` produces ~1-ULP different logits between S=1 (decode) and S>=2 (prefill). The drift compounds through DeepSeek-V4's 43 layers (amplified by mHC, Sinkhorn, 4-bit MoE) and flips the greedy argmax on long-prompt decode -- the S=1 / long-prompt failure documented in PR ml-explore#1189 review. Workaround in ``V4Attention.__call__``: when q has S=1 and ``sinks`` is present, duplicate q along the sequence axis to S=2, invoke the correct S>=2 kernel path, and slice the first output row back. Both padded rows see identical q/k/v/mask, so they produce identical outputs (verified by ``test_padded_q_rows_are_identical_in_output``) and the slice is exact. Cost: doubles SDPA compute for the decode step only -- negligible vs the rest of a 284B-MoE layer. Remove once mlx#3452 lands upstream. tests/test_v4_attention_sinks.py adds: - The padded-rows-equal invariant the workaround relies on. - An end-to-end smoke test of V4Attention with an S=1 input. Numerical equivalence to the antirez/ds4 reference oracle is deferred to the validation harness once the ds4 GGUF download completes. Existing tests/test_models.py V4 tests still pass (9/9, 1 skipped for missing torch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ivergence Root cause: CompressedKVCache.accumulate() applied APE using buffer-relative indices (0..ratio-1) instead of absolute position (pos % ratio). During decode at S=1, if the buffer started mid-window after a prefill remainder, APE indices were shifted — producing wrong compressed KV that cascaded into wrong attention scores on every subsequent token. Fix: Replace "buffer raw tokens, run full compressor at boundary" with ds4-style rolling state (ds4.c:6970-7034, logit-validated against official release): - Project each token immediately through wkv/wgate (one dispatch per token) - Apply APE using abs_pos % ratio (correct absolute position) - Maintain state_kv and state_score rolling buffers - Softmax-weighted pool at ratio boundary → RMSNorm → emit compressed row Cross-validated against antirez/ds4 reference implementation. Fixes: decode cache divergence reported by @anerjy on PR ml-explore#1189. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
We've been running this PR's 1. Decode slows down linearly with generated tokens (likely the cause of #1332)Decode starts ~29 tok/s and decays linearly (~4.3 µs/tok per generated token Cause: the Fix — pass the offset as a 0-d array (a traced input → one cache entry): def _offset_input(offset):
"""Wrap python-int cache offsets as 0-d arrays before mx.compile'd fns:
an array is a traced input (one compile-cache entry); a python int is a
per-value constant (one entry per token during decode)."""
if isinstance(offset, mx.array):
return offset
return mx.array(offset, dtype=mx.int32)- q, kv = _attn_qkv_partial_rope(q, kv, offset, rd, self.rope.freqs)
+ q, kv = _attn_qkv_partial_rope(
+ q, kv, _offset_input(offset), rd, self.rope.freqs
+ )- o = _attn_inv_rope_flatten(
- o, offset, rd, self.rope.freqs, self.n_heads * self.head_dim
- )
+ o = _attn_inv_rope_flatten(
+ o, _offset_input(offset), rd, self.rope.freqs,
+ self.n_heads * self.head_dim,
+ ) new_pooled = _compressor_norm_strided_rope(
new_pooled,
self.norm.weight,
self.norm.eps,
- int(pool_base) // ratio,
+ _offset_input(int(pool_base) // ratio),Verified: temp-0 outputs are byte-identical before/after; decode is flat 2. Top-k membership mask materializes [B, S, topk, T]In selected = (indexer_topk[..., None] == k_range[None, None, None, :]).any(axis=-2)At a 4096-token prefill chunk against a 32k-row pool (128k ctx) that's a - k_range = mx.arange(compressed_len, dtype=mx.int32)
- selected = (
- indexer_topk[..., None] == k_range[None, None, None, :]
- ).any(axis=-2)[:, None, :, :]
+ selected = mx.put_along_axis(
+ mx.zeros((B, S, compressed_len), dtype=mx.bool_),
+ indexer_topk.astype(mx.int64),
+ mx.ones(indexer_topk.shape, dtype=mx.bool_),
+ axis=-1,
+ )[:, None, :, :]Equality is exact ( The diffs above are the complete change in both cases — if you'd rather Co-authored with Claude Fable 5 (Anthropic) |
…quant predicate Both surfaced running the PR ml-explore#1189 test suite on mlx 0.32.0: - deepseek_v4.py used Any in an MTP-path annotation without importing it (NameError on module import in 8 tests). - convert.py's mixed_quant_predicate crashed on module paths whose depth differs from down_keys[0] (int('layers')); fall back to the first digit component. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
We ran this PR's 1. RoPE regime is per layer kind (ratio-4 attention cosine 0.926 → 0.999)The reference initializes one rope per layer: sparse layers ( 2. Compressed pool rows must be roped at birthThe reference applies 3. Compressed-row visibility mask: boolean masks invert/erase it
Also on the branch
Verification
Co-authored with Claude Fable 5 (Anthropic). |
|
Found a small blocker while running this PR on Apple Silicon: from typing import Dict, List, Optional # <- no AnyOn Python <= 3.13 this raises One-line fix: from typing import Any, Dict, List, OptionalVerified at 63a2662: import fails on 3.12.13, succeeds on 3.14.3; with the import fixed it loads fine on 3.12. Happy to open a patch PR against the branch if useful. (Context: running DeepSeek-V4-Flash-2bit-DQ through a benchmarking harness on an M4 Max — the arch itself has been generating fine for us under this PR once past the import.) |
|
Follow-up to my earlier comment (the Applying the two open PRs on the fork branch fixed it completely: machiabeli#5 (per-layer RoPE base + chunk-start RoPE for the compressed pool) and machiabeli#6 (causal allow-mask for pool columns). With both applied, generation is fully coherent on an M4 Max 128GB (2bit-DQ quant, ~90 GB resident, py3.14) — verified across greedy, temp 1.0, templated and raw prompts. So for anyone hitting garbage output on this PR: it's the compressed-attention RoPE + pool mask, and the fixes already exist on the fork. Would be great to see #5/#6 folded into this PR. |
DeepSeek-V4 ships no Jinja chat_template — its tokenizer_config.json carries
only BOS/EOS/pad — so the prompt has to be built programmatically. Today the
model would either raise in _apply_chat_template or fall back to naive
"role: content" concatenation, and its DSML tool calls would go unparsed.
This adds the three pieces needed to serve it.
**Prompt encoder** (`utils/deepseek_v4_encoding.py`). Ports the reference
`encoding_dsv4.py` published with the weights: bare system text after BOS,
roles delimited by <|User|>/<|Assistant|>, the turn closed with <think> in
thinking mode or </think> in chat mode, tool schemas rendered into the system
message, and tool results folded into the preceding user turn as <tool_result>
blocks (V4 has no tool role). reasoning_effort is a text prefix on the whole
conversation rather than a token or sampling parameter. drop_thinking is forced
off when tools are present, because the model needs to see why it made the
earlier calls.
`utils/tokenizer.py` installs the encoder by overriding apply_chat_template on
the tokenizer when model_type is deepseek_v4. That fixes all three call sites
at once — both engines and models/llm.py reach the template through that one
method — without touching any of them.
**DSML tool parser** (`tool_parsers/deepseek_v4_tool_parser.py`). V4 emits its
own markup, not JSON:
<|DSML|invoke name="get_weather">
<|DSML|parameter name="city" string="true">Prague</|DSML|parameter>
<|DSML|parameter name="days" string="false">3</|DSML|parameter>
</|DSML|invoke>
The `string` attribute carries the type, so a scanner is required rather than a
regex over name=value pairs — a string parameter may legitimately contain
quotes, angle brackets or a JSON-looking payload. The existing
DeepSeekToolParser handles the V3/R1 format and shares nothing with this.
The parser declares SUPPORTS_NATIVE_TOOL_FORMAT, because the encoder consumes
role="tool" messages and assistant tool_calls directly. Declaring otherwise
would have the server flatten them into "[Tool Result (id)]: ..." and
"[Calling tool: name(...)]" text first (api/utils.py), so the model would see a
shape it was never trained on and the encoder's own handling would never run.
That path also decodes tool call arguments in place, so the encoder accepts
them either as the JSON string the wire format uses or as an already-decoded
mapping; json-loading a mapping again collapsed every parameter into one bogus
"arguments" entry.
**Reasoning parser** (`reasoning/deepseek_v4_parser.py`). Extends the R1 parser,
which already tolerates the missing opening <think> that the encoder's prompt
implies. What V4 adds is that a tool call must follow completed reasoning, so an
opening <|DSML|tool_calls> terminates the reasoning block even when </think>
never arrives; without it the whole DSML payload is swallowed as reasoning and
the caller sees no tool call.
Both parsers track how much of the accumulated text they have emitted and
withhold a tail that could still grow into a marker. <|DSML|tool_calls> is
assembled from several tokens — only the bare |DSML| has an id — so it always
straddles delta boundaries. Detecting completion against the delta instead of
the accumulated text loses the calls entirely, and emitting marker fragments as
they arrive leaks markup into the user-visible stream and then repeats it.
Model loading is out of scope: vllm-mlx defines no model architectures, and
deepseek_v4 lands in mlx-lm via ml-explore/mlx-lm#1189. Everything here is text
processing and works the moment that merges.
Verified against the real model — DeepSeek-V4-Flash-0731 MXFP4, 167 GB resident
on an M3 Ultra — with 23 scenarios spanning chat, thinking, all reasoning_effort
levels, single and parallel tool calls, tool-result round trips and multi-turn
history. Prompts were built by this encoder and the completions run back through
these parsers, both individually and chained the way the server chains them:
127/128 checks pass. The one failure is a scenario truncated at the token cap
before it emitted </think>, where treating the output as content is correct. The
model's DSML matched the specification, including the string="true|false" type
flag, and parallel calls came back intact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DeepSeek-V4 ships no Jinja chat_template — its tokenizer_config.json carries
only BOS/EOS/pad — so the prompt has to be built programmatically. Today the
model would either raise in _apply_chat_template or fall back to naive
"role: content" concatenation, and its DSML tool calls would go unparsed.
This adds the three pieces needed to serve it.
**Prompt encoder** (`utils/deepseek_v4_encoding.py`). Ports the reference
`encoding_dsv4.py` published with the weights: bare system text after BOS,
roles delimited by <|User|>/<|Assistant|>, the turn closed with <think> in
thinking mode or </think> in chat mode, tool schemas rendered into the system
message, and tool results folded into the preceding user turn as <tool_result>
blocks (V4 has no tool role). reasoning_effort is a text prefix on the whole
conversation rather than a token or sampling parameter. drop_thinking is forced
off when tools are present, because the model needs to see why it made the
earlier calls.
`utils/tokenizer.py` installs the encoder by overriding apply_chat_template on
the tokenizer when model_type is deepseek_v4. That fixes all three call sites
at once — both engines and models/llm.py reach the template through that one
method — without touching any of them.
**DSML tool parser** (`tool_parsers/deepseek_v4_tool_parser.py`). V4 emits its
own markup, not JSON:
<|DSML|invoke name="get_weather">
<|DSML|parameter name="city" string="true">Prague</|DSML|parameter>
<|DSML|parameter name="days" string="false">3</|DSML|parameter>
</|DSML|invoke>
The `string` attribute carries the type, so a scanner is required rather than a
regex over name=value pairs — a string parameter may legitimately contain
quotes, angle brackets or a JSON-looking payload. The existing
DeepSeekToolParser handles the V3/R1 format and shares nothing with this.
The parser declares SUPPORTS_NATIVE_TOOL_FORMAT, because the encoder consumes
role="tool" messages and assistant tool_calls directly. Declaring otherwise
would have the server flatten them into "[Tool Result (id)]: ..." and
"[Calling tool: name(...)]" text first (api/utils.py), so the model would see a
shape it was never trained on and the encoder's own handling would never run.
That path also decodes tool call arguments in place, so the encoder accepts
them either as the JSON string the wire format uses or as an already-decoded
mapping; json-loading a mapping again collapsed every parameter into one bogus
"arguments" entry.
**Reasoning parser** (`reasoning/deepseek_v4_parser.py`). Extends the R1 parser,
which already tolerates the missing opening <think> that the encoder's prompt
implies. What V4 adds is that a tool call must follow completed reasoning, so an
opening <|DSML|tool_calls> terminates the reasoning block even when </think>
never arrives; without it the whole DSML payload is swallowed as reasoning and
the caller sees no tool call.
Both parsers track how much of the accumulated text they have emitted and
withhold a tail that could still grow into a marker. <|DSML|tool_calls> is
assembled from several tokens — only the bare |DSML| has an id — so it always
straddles delta boundaries. Detecting completion against the delta instead of
the accumulated text loses the calls entirely, and emitting marker fragments as
they arrive leaks markup into the user-visible stream and then repeats it.
Model loading is out of scope: vllm-mlx defines no model architectures, and
deepseek_v4 lands in mlx-lm via ml-explore/mlx-lm#1189. Everything here is text
processing and works the moment that merges.
Verified against the real model — DeepSeek-V4-Flash-0731 MXFP4, 167 GB resident
on an M3 Ultra — with 23 scenarios spanning chat, thinking, all reasoning_effort
levels, single and parallel tool calls, tool-result round trips and multi-turn
history. Prompts were built by this encoder and the completions run back through
these parsers, both individually and chained the way the server chains them:
127/128 checks pass. The one failure is a scenario truncated at the token cap
before it emitted </think>, where treating the output as content is correct. The
model's DSML matched the specification, including the string="true|false" type
flag, and parallel calls came back intact.
A benchmark under `benchmarks/bench_deepseek_v4.py` covers both serving paths:
single-stream decode, where the parsers sit in the latency path, and batched
decode with N interleaved sequences, which is the shape continuous batching
produces. Prompt encoding costs 0.02-0.34 ms depending on history length.
Batching adds no per-token cost — each request carries independent parser state
and totals scale linearly to 16 streams at 0.0107 ms/tok. The DSML parser's own
per-token cost is flat, 0.0018 to 0.0028 ms/tok between 100 and 5000 tokens.
The combined single-stream path does grow, 0.006 to 0.038 ms/tok over the same
range. That is 0.2% of the decode budget at 50 tok/s, but it is quadratic and it
comes from BaseThinkingReasoningParser searching the accumulated text for its
tags on every delta while reasoning is open — the benchmark isolates the tool
parser to show it. Worth addressing in the base class rather than per model, so
it is left alone here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
I opened a consolidated draft patch against It folds in and explicitly credits the RoPE, compressed-mask, Sinkhorn, cache-safety, 0731 parity, and Python import findings already reported by @onchainengineer, @OmarB97, and @ncdrone. It also adds official compressor rolling-state parity tests, vector-offset mask handling, 0731 DSpark target-only detection, and a fail-closed speculative-rollback gate. The draft is intentionally narrow: it does not claim full 0731 parity, DSpark support, a source-faithful indexer, prompt-cache serialization, generic batching/KV-cache quantization, long-context quality, or performance. The focused CPU suite is 11/11, and an independent final review approved the frozen diff. |
|
@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 |
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