Add DeepSeek-v4 (Flash/Pro) - #1192
Conversation
|
You can now run it on a 256GB Mac by keeping a experts in 4bit! We could do 5bit since it's much better than 4bit right now. I'm open to opinions @angeloskath
|
|
Hey @Blaizzy — just flagging some technical notes since we're both working on V4 support and PR #1189 landed ~10 hours earlier with significant overlap: Compressed attention mask direction (line 770-773): Sinkhorn normalization: sqrtsoftplus numerical stability: Happy to coordinate if the maintainers want to consolidate into one PR. Our implementation has live generation validation at 21.86 tok/s on M3 Ultra (DeepSeek-V4-Flash-4bit, 160GB peak). |
|
Hey @machiabeli, thanks! Yes, same person who left the earlier feedback, good to connect properly. I've been poking at this in parallel and landed on something close to the source numerically with minimal changes, but there's definitely room to combine approaches. A PR from you on the compressed attention mask, Sinkhorn norm, and sqrt-softplus would be really welcome, happy to review and merge what works best. Or I can cherry pick and add you as a co-author. |
| if ( | ||
| config.get("quantization", None) is None | ||
| and getattr(model_args, "quantization", None) is not None | ||
| and any(k.endswith(".scales") for k in weights) | ||
| ): | ||
| config["quantization"] = model_args.quantization | ||
|
|
||
| def _quantize(quantization): | ||
| def class_predicate(p, m): | ||
| if not hasattr(m, "to_quantized"): | ||
| return False | ||
| if f"{p}.scales" not in weights: | ||
| return False | ||
| # Handle custom per layer quantizations | ||
| if p in config["quantization"]: | ||
| return config["quantization"][p] | ||
| if not hasattr(m, "to_quantized"): | ||
| return False | ||
| return f"{p}.scales" in weights | ||
| return True |
There was a problem hiding this comment.
The goal here is to preserve mxfp4 expert quant since MLX supports it. So I made the quantize_config key in the config class default to that, and these changes help prequantized models load properly.
It can be done via predicate but couldn't find an elegant way of doing it.
Note: it doesn't affect any model.
There was a problem hiding this comment.
Alternative is to dequant -> requant similar to how we do with FP8.
…ponding unit tests for HyperConnection
… with matmul for improved performance
…c10538) 4 new commits: - 5c10538 Fix tensor parallel distributed - 7efb57f Fix batch cache edge case - c58834b Add hyper_connection.py (moves HyperConnection out of deepseek_v4.py) - ca8b299 Simplify HyperConnection Auto-merge clean — our EXO_DSV4_INDEX_TOPK env override on Indexer.__init__ and the @mx.compile _indexer_score helper both survived. Verified file parses.
|
Prompt longer than 2K leads to no response at all I've used https://raw.githubusercontent.com/ivanfioravanti/llm_context_benchmarks/refs/heads/master/4k.txt |
|
convert is not working properly, 5bit or 6 bit, use 4.349 bits per weight. ❯ mlx_lm.convert --hf-path deepseek-ai/DeepSeek-V4-Flash -q --q-bits 6 --mlx-path ~/DeepSeek-V4-Flash-6bit It seems related to the fact that starting from FP8 model, mxp4 + mxfp8 is automatically applied independently from --q-bits. |
The pinned mlx-lm v0.31.3 has no DeepSeek V4 support. Bring it in through the omlx/patches pattern rather than touching the upstream package. New `omlx/patches/deepseek_v4/`: - 1:1 copies of PR 1192's `deepseek_v4.py`, `hyper_connection.py`, and the `PoolingCache` / `BatchPoolingCache` additions to `cache.py`. - Function-replacement patches for `mlx_lm.utils.load_model` (F8_E8M0 dtype fallback + fp8 quant branch) and `mlx_lm.generate._make_cache` (PoolingCache → BatchPoolingCache). - `AutoTokenizer` wrapper that retries with `PreTrainedConfig()` when transformers ≤5.7.0 does not yet recognize `model_type=deepseek_v4`. Becomes dead code automatically once transformers ships native support. - `PoolingCacheHandler` / `BatchPoolingCacheHandler` registered with the omlx CacheTypeRegistry so SSD and prefix cache state extraction do not silently fall through to `DefaultCacheHandler`. omlx core: conditional dispatch in `utils/model_loading.py`, `engine/batched.py`, and `models/llm.py` (gated on `config.json::model_type=="deepseek_v4"` so other models pay nothing); two new enum values and class-name mappings in `cache/type_handlers.py` and `cache/type_registry.py`. 31 new unit tests in `tests/test_deepseek_v4_patch.py`. 344 cache / engine / patches regression tests still pass. Huge thanks to @Blaizzy for the upstream V4 implementation in ml-explore/mlx-lm#1192 — this commit is just a thin glue layer around that work.
|
did you quantize these by upcasting fp8/fp4+scales to fp32? that is the only way, correct? |
|
I just added tool parsing support for this branch here: Blaizzy#22 In theory, it should work with #1189 too. If you're using https://huggingface.co/collections/mlx-community/deepseek-v4 weights, you'll need to update chat_template.jinja to: https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash/blob/014a5cfe6d1349d3d1096b2f8c15faaaa11819d5/chat_template.jinja Light usage via Claude Code has been smooth so far, but probably needs a bit more testing before I'd say this is solid. |
This comment was marked as outdated.
This comment was marked as outdated.
|
Current branch also seems to trigger |
|
Thanks! I left a comment @spicyneuron |
|
Hey @Blaizzy, thanks for your work! I tested this branch on an M5 Max 128 GB, trying to requantize DeepSeek-V4-Flash from FP8 source to lower-precision affine (2-3 bpp) via The current # deepseek_v4.py, Model.sanitize(), line 1082-1084 — works for inference
elif weight.dtype == mx.uint8:
new_weights[k + "s"] = mx.repeat(mx.repeat(v, 4, -1), 128, 0)
new_weights[wk] = weight.view(mx.uint32)But there's no path in The fix is to use elif weight.dtype == mx.uint8:
# Dequantize FP8 E4M3 weights with E8M0 block scales to BF16
m, n = weight.shape
sm, sn = v.shape
w32 = weight.view(mx.uint32)
target_sn = w32.shape[-1] // 8 # MXFP8 group_size=32 → 8 uint32 per scale
s_expanded = mx.repeat(mx.repeat(v, target_sn // sn, -1), m // sm, 0)
new_weights[wk] = mx.dequantize(w32, s_expanded, group_size=32, bits=8, mode="mxfp8")Same pattern applies to the FP4 expert branch ( # FP4 experts → BF16
w32 = weight.view(mx.uint32)
new_weights[wk] = mx.dequantize(w32, v, group_size=32, bits=4, mode="mxfp4").astype(mx.bfloat16)These dequantization paths would go behind a flag (e.g., triggered when After this fix, |
* feat: port DeepSeek-V4 model + MTP speculative decoding Adds DeepseekV4Model (base model port) and MTPCapable conformance via DeepseekV4MTPBlock, following the same omlx patterns as the Qwen3.5 MTP integration. Registers "deepseek_v4" in LLMTypeRegistry. Source PRs: - mlx-lm base model by Blaizzy, 0xClandestine, angeloskath, pcuenca, eauchs ml-explore/mlx-lm#1192 - mlx-lm fork MTP support by 0xClandestine Blaizzy/mlx-lm#15 * fix: resolve all compilation errors in DeepSeek-V4 port - Replace BaseKVCache subclass with direct KVCache conformance (init/offset/maxSize are not open outside MLXLMCommon) - Rename MultiLinear → DeepseekV4MultiLinear to avoid ambiguity with the identically-named class in GLM4MOELite.swift - Fix .bool_ → .bool and use MLXArray.ones([L,P], dtype: .bool) - Split mixed var/let compound declarations in overlapCompressKV - Fix full(values:) to accept MLXArray(-Float.infinity) - Fix stacked(stacked) → MLX.stacked(stacked) in sanitize - Fix expandedDimensions(axis: [0,1]) → chained single-axis calls - Add @unknown default to SDPA mask switch - MTP: pass layerIdx to DeepseekV4Block init - MTP: pass inputIds to block.callAsFunction - MTP: update createAttentionMask to non-deprecated single-cache API
|
I did several rounds of brute force Codex / Opus comparisons between this branch vs transformers and vLLM implementations. Results below. I'm testing out rough fixes on my own server to see if it helps with the looping / long context issues. But in the meantime, anyone else want to validate? Cross-reference:
|
| Topic | pr-1192 |
vLLM | HF transformers | Note |
|---|---|---|---|---|
LocalAttention RoPE scaling |
Passes None for rope_scaling on local layers (no YaRN). |
Single get_rope per layer; YaRN/mscale apply uniformly, only rope_theta swaps between compress_rope_theta and rope_theta (L1009-1031). |
One shared DeepseekV4RotaryEmbedding(config); sliding/local layers (compressor=None) read the same main rope-type that carries scaling (L740-763). |
pr-1192 deviates — local layers should use config.rope_scaling. |
| MoE gate dtype | logits = x @ self.weight.T in activation dtype. |
router_logits_dtype=torch.float32 (L853). |
F.linear(flat.float(), self.weight.float()) in both TopK and Hash routers (L979, L1010). |
pr-1192 deviates — gate must be fp32. |
| Compressor softmax precision | _simple_compress_kv (L286-290) casts gate logits to fp32 before softmax, but omits precise=True; _overlap_compress_kv (L293-310) casts ape down to gate.dtype (bf16/fp16 from wgate), so its softmax input stays low precision even though it passes precise=True. |
Compressor state is fp32 throughout (assert self.dtype == torch.float32, L143). |
HCA, Indexer, and CSA all do softmax(..., dtype=torch.float32) (L419, L520, L629-L630). |
pr-1192's two compress paths trade off precision in opposite directions; neither matches HF/vLLM's uniformly fp32 compressor softmax. |
| CSA cross-call overlap state | _overlap_compress_kv prepends a zero/-inf row at every call — overlap is reset each chunk, breaking long-context generation. |
Compressor stores prior kv_a/gate_a slice in cache state and pulls it forward across calls. |
Same — overlap is part of CSA layer state, not zeroed per-call. | pr-1192 is functionally incorrect for chunked prefill or multi-call generation; the new DeepseekV4PoolingCache carries overlap_kv/overlap_gate across calls. |
| Pooling cache storage growth | Each update_and_fetch does mx.concatenate([self.pooled, px], axis=1), accumulating lazy-graph nodes until Metal's per-command-buffer resource limit aborts long runs. |
N/A (PyTorch/CUDA — no equivalent constraint). | N/A (PyTorch — no equivalent constraint). | MLX-specific. pr-1192 should step-allocate a backing buffer (e.g. 256-token chunks) and expose pooled as a logical-size view into it. |
|
Superseding my previous comment on model looping, I've isolated a script that reliably reproduces the issue at ~4000 tokens on my Mac Studio M3. Could someone else try it? # just in case
uv cache prune
# serve this branch
uvx --from git+https://github.com/Blaizzy/mlx-lm/mlx-lm@pc/add-deepseekv4flash-model \
mlx_lm.server \
--max-tokens 32000 \
--model mlx-community/DeepSeek-V4-Flash-8bit
# and then
uv run repro_v4_loop_minimal.py --base-url http://localhost:8080 --min-p 0.0
# still reproduces, but later
uv run repro_v4_loop_minimal.py --base-url http://localhost:8080 --min-p 0.05Runs sometimes fail with the EDIT: Tentative fix in this branch, but I'd be more confident in it if others also can reproduce the bug, and then see it go away when using: uvx --from git+https://github.com/spicyneuron/mlx-lm/mlx-lm@fix-ds4-cache-reuse \
mlx_lm.server \
--max-tokens 32000 \
--model mlx-community/DeepSeek-V4-Flash-8bit |
I gave a try on a machine I have access to, I pretty much confirm same behavior, though I couldn't recreate with min-p 0.05 - I gave it just a few tries though mlx-lm DeepSeek V4 Flash reproI ran the repro on a Mac Studio M3 Ultra and can confirm that the baseline branch reproduces the loop, while One command difference: the original command in the comment did not work for me because the git URL had an extra I used this corrected baseline command instead: uvx --from git+https://github.com/Blaizzy/mlx-lm@pc/add-deepseekv4flash-model \
mlx_lm.server \
--max-tokens 32000 \
--model mlx-community/DeepSeek-V4-Flash-8bitAnd this corrected fix-branch command: uvx --from git+https://github.com/spicyneuron/mlx-lm@fix-ds4-cache-reuse \
mlx_lm.server \
--max-tokens 32000 \
--model mlx-community/DeepSeek-V4-Flash-8bitBaseline, Fix branch, For After restarting the server and retrying, the run completed without a loop (2 times): Summary from my run:
|
|
Heads up: the mlx-community DeepSeek-V4-Flash conversions (the
A minimal patch on top of `pc/add-deepseekv4flash-model` that covers all three: ```python extend the existing top_remap dict with:"embed.scales": "model.embed_tokens.scales", before the existing per-expert stacking loop, rename pre-stacked experts:for layer_idx in range(n_layers): before the existing 2D->3D wo_a reshape, stack split wo_a shards:for layer_idx in range(n_layers): Verified with both 3-bit (124 GB on disk) and 4-bit (149 GB on disk) on a M2 Ultra (192 GB unified). Both load (peak 124.6 GB and 160.2 GB respectively) and generate at ~33–35 tok/s. Separately, neither of those repos ships a `chat_template`; copying the one from `inferencerlabs/DeepSeek-V4-Flash-MLX-2.8bit-EXP` fixes runaway USER/ASSISTANT continuation in `mlx_lm.server`. Happy to open a PR against your branch if useful. |
|
Several people in this thread are hitting Root cause: an unbounded per‑decode‑step leak of live Metal buffers in the DeepSeek‑V4 attention caches.
The fix materializes the per‑layer cache state once per forward pass ( @Blaizzy — happy to adjust the placement if you'd prefer to fold it into this PR directly. |
|
So the code review burden on this model is clearly huge. But I can attest that DS 4 Flash runs incredibly well on Apple hardware, and it feels like a waste that this isn't available to more people. Open question to @angeloskath @Blaizzy @kernelpool @pcuenca and others — how can we move this forward? On my end, I was able to patch my way to a stable implementation based on this PR, using transformers and vllm as references. No Code likely suffers from LLM-isms, but I'll put it forward as a rough implementation that others might be able to streamline or borrow from. I had Codex squash the history into a cleaner commit story, so each fix should be reviewable in isolation: Blaizzy/mlx-lm@pc/add-deepseekv4flash-model...spicyneuron:mlx-lm:pr-1192-ds4-clean |
|
Are there any talents still interested in this PR? |
|
@hehua2008 Have you tried @antirez ‘s https://github.com/antirez/ds4 ? |
|
Thank you for reminding. I will try it. |
|
Local LLMs are still a niche use case, and AI-coded PRs have added a lot of noise to many open source projects. So it's not surprising things are slow. But without a core maintainer to champion things, complex PRs are effectively dead ends. So at this point, best we can do is show interest, try to make code review easy, and run our own forks if we need something faster. |
|
Closing this PR for now |


Note: Please install this transformers PR from source to avoid tokenizer bugs.
pip install git+https://github.com/huggingface/transformers.git@refs/pull/45643/headWeights here:
https://huggingface.co/collections/mlx-community/deepseek-v4