Skip to content

Add DeepSeek-v4 (Flash/Pro) - #1192

Closed
Blaizzy wants to merge 62 commits into
ml-explore:mainfrom
Blaizzy:pc/add-deepseekv4flash-model
Closed

Add DeepSeek-v4 (Flash/Pro)#1192
Blaizzy wants to merge 62 commits into
ml-explore:mainfrom
Blaizzy:pc/add-deepseekv4flash-model

Conversation

@Blaizzy

@Blaizzy Blaizzy commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Note: Please install this transformers PR from source to avoid tokenizer bugs.

pip install git+https://github.com/huggingface/transformers.git@refs/pull/45643/head

Weights here:
https://huggingface.co/collections/mlx-community/deepseek-v4

image

@Blaizzy Blaizzy changed the title Add DeepSeekv4 (Flash/Pro) Add DeepSeek-v4 (Flash/Pro) Apr 24, 2026
@Blaizzy

Blaizzy commented Apr 24, 2026

Copy link
Copy Markdown
Contributor Author

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

image

@Blaizzy

Blaizzy commented Apr 24, 2026

Copy link
Copy Markdown
Contributor Author

It's faster now!
Screenshot 2026-04-24 at 21 54 13

Comment thread mlx_lm/utils.py
Comment thread mlx_lm/models/deepseek_v4.py Outdated
@machiabeli

Copy link
Copy Markdown

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):
The mask padding for compressed KV rows uses mx.ones, but create_attention_mask returns negative values for blocked positions. Padding with ones would block attention to compressed rows rather than allow it. PR #1189 uses mx.zeros here.

Sinkhorn normalization:
The Python loop path (line 222-226) dispatches ~40 kernel launches per call (softmax + iters x sum + div). PR #1189 has a fused Metal kernel that does this in a single register-resident dispatch — benchmarked at 3.5-5.7x faster on micro, 1.83x end-to-end.

sqrtsoftplus numerical stability:
nn.softplus(x) can overflow for large scores. PR #1189 uses mx.logaddexp(scores, zeros) which is log-sum-exp stable.

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).

@Blaizzy

Blaizzy commented Apr 24, 2026

Copy link
Copy Markdown
Contributor Author

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.

Comment thread mlx_lm/utils.py Outdated
Comment on lines +395 to +411
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

@Blaizzy Blaizzy Apr 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternative is to dequant -> requant similar to how we do with FP8.

@ivaniguarans

Copy link
Copy Markdown

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 mlx_lm.convert.

The current sanitize in mlx_lm/models/deepseek_v4.py (Model.sanitize()) correctly converts FP8 weights to MXFP8 quantized format for native inference:

# 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 Model.sanitize() to dequantize FP8 weights back to BF16 for requantization. The naive approach — weight.astype(mx.bfloat16) — treats uint8 bytes as integers (0–255) instead of decoding them as FP8 E4M3 floats. This produces weight values ~20,000× too large (mean ~20000 vs expected ~0.016), causing immediate numerical explosion in layer 0.

The fix is to use mx.dequantize with MXFP8 mode, which properly decodes the FP8 values:

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 (Model.sanitize(), line 1074-1081) — use mx.dequantize(..., mode="mxfp4") instead of manual reinterpretation:

# 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 convert is called with -q on an FP8 source) so the existing MXFP8 inference path stays untouched.

After this fix, mlx_lm.convert -q --q-bits 2 --q-group-size 64 produces a working 85 GB model (2.56 bpp) from the 149 GB FP8 source. The model generates coherent output for short queries. (2-bit is too aggressive for sustained quality on this architecture, but the dequantization itself is correct — verified by checking weight statistics and layer-0 activation norms.)

0xClandestine added a commit to Layr-Labs/mlx-swift-lm that referenced this pull request May 8, 2026
* 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
@spicyneuron

Copy link
Copy Markdown
Contributor

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: pr-1192 vs. vLLM and HF transformers

Comparison sources:

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.

@spicyneuron

spicyneuron commented May 11, 2026

Copy link
Copy Markdown
Contributor

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.05

Runs sometimes fail with the RuntimeError: [metal::malloc] Resource limit (499000) I mentioned above but simply restarting the server and trying again works to bypass the issue (eventually).


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

@kidroca

kidroca commented May 14, 2026

Copy link
Copy Markdown

@spicyneuron

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?

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 repro

I ran the repro on a Mac Studio M3 Ultra and can confirm that the baseline branch reproduces the loop, while fix-ds4-cache-reuse fixes the case I could reproduce.

One command difference: the original command in the comment did not work for me because the git URL had an extra /mlx-lm path segment:

git+https://github.com/Blaizzy/mlx-lm/mlx-lm@pc/add-deepseekv4flash-model

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-8bit

And 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-8bit

Baseline, --min-p 0.0:

seed=0 min_p=0.0
turn=1 tokens=4000 status=LOOP
loop='consequential—was consequential—was consequential—was consequential—was consequential—was consequential—was consequential—was consequential—was' x671

Fix branch, --min-p 0.0:

seed=0 min_p=0.0
turn=1 tokens=4000 status=ok
turn=2 tokens=4000 status=ok
no loop reproduced

For --min-p 0.05, I hit the known Metal resource-limit/server-disconnect path on one attempt:

RuntimeError: [metal::malloc] Resource limit (499000) exceeded.
http.client.RemoteDisconnected: Remote end closed connection without response

After restarting the server and retrying, the run completed without a loop (2 times):

seed=0 min_p=0.05
turn=1 tokens=4000 status=ok
turn=2 tokens=4000 status=ok
no loop reproduced

Summary from my run:

  • baseline reproduces the loop with min_p=0.0
  • fix-ds4-cache-reuse removes that reproduced loop
  • min_p=0.05 did not reproduce the loop for me after retrying past the Metal resource-limit failure

@bojiang

bojiang commented May 19, 2026

Copy link
Copy Markdown

Heads up: the mlx-community DeepSeek-V4-Flash conversions (the mlx-community/deepseek-ai-DeepSeek-V4-Flash-{2,3,4,6,8}bit and -fp16 repos, all uploaded 2026-04-24/25) ship weights in a format that the current sanitize doesn't recognize. Two specific gaps observed on a 4-bit load:

  1. embed.{scales,biases} / head.{scales,biases} aren't in top_remap — only the .weight keys are, so the quantized scales/biases never get renamed to model.embed_tokens.* / lm_head.*.
  2. Experts are pre-stacked under ffn.experts.w{1,2,3}.{weight,scales,biases} instead of per-expert ffn.experts.E.w{1,2,3}.{...}. The existing expert-stacking block expects the per-expert form and so silently leaves the pre-stacked tensors un-renamed.
  3. wo_a is split into o_groups separate Linear modules under attn.wo_a.{0..o_groups-1}.{weight,scales,biases} instead of a single 2D tensor. The current wo_a reshape only handles a 2D-tensor input.

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",
"embed.biases": "model.embed_tokens.biases",
"head.scales": "lm_head.scales",
"head.biases": "lm_head.biases",

before the existing per-expert stacking loop, rename pre-stacked experts:

for layer_idx in range(n_layers):
prefix = f"model.layers.{layer_idx}.ffn.experts"
for src, dst in (("w1", "gate_proj"), ("w2", "down_proj"), ("w3", "up_proj")):
for suffix in ("weight", "scales", "biases"):
old_k = f"{prefix}.{src}.{suffix}"
new_k = f"model.layers.{layer_idx}.ffn.switch_mlp.{dst}.{suffix}"
if old_k in weights and new_k not in weights:
weights[new_k] = weights.pop(old_k)

before the existing 2D->3D wo_a reshape, stack split wo_a shards:

for layer_idx in range(n_layers):
prefix = f"model.layers.{layer_idx}.attn.wo_a"
for suffix in ("weight", "scales", "biases"):
key0 = f"{prefix}.0.{suffix}"
if key0 in weights:
stacked = [weights.pop(f"{prefix}.{g}.{suffix}") for g in range(self.args.o_groups)]
weights[f"{prefix}.{suffix}"] = mx.stack(stacked, axis=0)
```

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.

@snagnever

Copy link
Copy Markdown

Several people in this thread are hitting [metal::malloc] Resource limit (499000) exceeded during long or looping generations on Apple Silicon (and the command queue then wedges until a restart). I tracked down the cause and have a fix.

Root cause: an unbounded per‑decode‑step leak of live Metal buffers in the DeepSeek‑V4 attention caches. PoolingCache (concatenate‑grow) and RotatingKVCache (sliced assignment), plus their Batch* variants, build per‑step lazy graphs that are never detached — the cache holds the head of the chain, so every prior step's intermediate array and its backing Metal buffer stays resident. resource_limit (499000) is a count of live resident buffers, not bytes, so the count climbs ~one per layer per step and hits the cap at ~11.3K tokens regardless of prompt length (≈ 499000 / 43 layers). Only ~2–3 GB is actually leaked at that point — it's the count that's exhausted, which is why no set_memory_limit/set_wired_limit knob helps.

The fix materializes the per‑layer cache state once per forward pass (mx.eval), which detaches the chains and keeps the live‑buffer count bounded. Verified on M4 Max 128 GB / mlx 0.31.2: forced 20K‑token generation clean to 19,989 (was OOM at 11,314), 31.3 tok/s (no regression), and a 300‑request knowledge‑bench soak on a single long‑lived server with 0 OOMs.

@Blaizzy — happy to adjust the placement if you'd prefer to fold it into this PR directly.

@spicyneuron

spicyneuron commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

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 Resource limit (499000), no catastrophic looping, and tool calling works beautifully.

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

@hehua2008

Copy link
Copy Markdown

Are there any talents still interested in this PR?
Looking forward to your amazing work! Thanks~
I miss @awni ‘s hard work😭

@siddharthjthapa

Copy link
Copy Markdown

@hehua2008

Copy link
Copy Markdown

Thank you for reminding. I will try it.
But have you found that the Apple MLX team has almost ignored this project? This project has not released an updated version for 2 months! The great @awni has left Apple MLX to join Anthropic. Now only volunteers are submitting PR. It shows that Apple's investment in AI is getting less and less, which is different from what they advertise. This is what makes me very disappointed……

@spicyneuron

Copy link
Copy Markdown
Contributor

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.

@Blaizzy

Blaizzy commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

Closing this PR for now

@Blaizzy Blaizzy closed this Jun 19, 2026
janhilgard added a commit to janhilgard/vllm-mlx that referenced this pull request Aug 3, 2026
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#1192. 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.
Model support comes from mlx-lm PR #1192, verified against this branch on a
DeepSeek-V4-Flash-0731 MXFP4 checkpoint (283.8 B params, M3 Ultra): correct DSML
for single, parallel and mixed-tool calls including integer arguments,
deterministic at temperature 0, and factually right on a short QA set — 9/9.
31 tok/s single-stream, 90 tok/s aggregate at concurrency 8.

Two other open PRs load the same checkpoint and should be avoided: #1189 and
#1195 both emit "<|DSML|tool_c天气>" instead of "<|DSML|tool_calls>", so tool
calls silently fail while free text still reads fine. The corruption traces to
the model implementation, not to tokenisation or batching: the input token ids
are identical, both tokenizers decode the marker correctly, and layer-by-layer
hidden-state norms diverge from the published reference by 1.7% at layer 0,
growing past 20% from layer 5 as the MoE router starts selecting different
experts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
janhilgard added a commit to janhilgard/vllm-mlx that referenced this pull request Aug 3, 2026
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#1192. 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.
Model support comes from mlx-lm PR #1192, verified against this branch on a
DeepSeek-V4-Flash-0731 MXFP4 checkpoint (283.8 B params, M3 Ultra): correct DSML
for single, parallel and mixed-tool calls including integer arguments,
deterministic at temperature 0, and factually right on a short QA set — 9/9.
31 tok/s single-stream, 90 tok/s aggregate at concurrency 8.

Two other open PRs load the same checkpoint and should be avoided: #1189 and
#1195 both emit "<|DSML|tool_c天气>" instead of "<|DSML|tool_calls>", so tool
calls silently fail while free text still reads fine. The corruption traces to
the model implementation, not to tokenisation or batching: the input token ids
are identical, both tokenizers decode the marker correctly, and layer-by-layer
hidden-state norms diverge from the published reference by 1.7% at layer 0,
growing past 20% from layer 5 as the MoE router starts selecting different
experts.

Pin model load and generation to one thread
-------------------------------------------

Serving this model surfaced a threading bug that made every request fail with
HTTP 500, so the fix is included here to keep this branch runnable.

MLX streams exist only in the thread that created them, and an array with
pending primitives carries the stream those primitives were built on, so a
model loaded on one thread cannot be driven from another. SimpleEngine spread
that work over three threads: prepare_for_start() and the streaming routes ran
on the event loop, while _run_blocking_serialized() hopped to asyncio.to_thread.
It held together only while all three agreed; once a request took a different
route than the one that built the prompt cache, generation died in mx.eval with
"There is no Stream(gpu, N) in current thread".

The scattered bind_generation_streams() calls cannot fix this: rebinding a
module-level handle changes a global the existing buffers never consult. I
measured all three candidates on a cache built on one thread and evaluated on
another — per-worker rebinding failed 3/3, keeping mlx-lm's import-time
ThreadLocalStream failed 3/3, pinning passed 0/3.

- _generation_worker(), one ThreadPoolExecutor(max_workers=1) per engine, as
  BatchedEngine already does in engine_core.py
- model load moved onto it, in start() and in the residency manager, which had
  its own load path holding prepare_for_start on the event loop thread
- both streaming routes pumped through it with a _STREAM_DONE sentinel, since
  StopIteration cannot cross a thread boundary; generator close() too
- bind_generation_streams() docstring corrected

Generation no longer blocks the event loop, so genuinely concurrent requests
now reach admission control where "fail_fast" rejects them; that was previously
masked. The default is unchanged — operators wanting queuing over 503s set
VLLM_MLX_SIMPLE_ENGINE_LOCK_ADMISSION=wait.

Submitted standalone against main as waybarrios#679 as well, since it is independent of
DeepSeek support.

Verified on DeepSeek-V4-Flash-0731 (283.8B, MXFP4, M3 Ultra): 30/30 non-stream
requests and 18/18 streaming requests pass with 0 stream errors, tool calls
parse 6/6 non-stream and 3/3 streaming, streaming is genuinely incremental
(81 reasoning + 39 content chunks on one response), and decode holds at
30.4 tok/s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
janhilgard added a commit to janhilgard/vllm-mlx that referenced this pull request Aug 3, 2026
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#1192. 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.
Model support comes from mlx-lm PR #1192, verified against this branch on a
DeepSeek-V4-Flash-0731 MXFP4 checkpoint (283.8 B params, M3 Ultra): correct DSML
for single, parallel and mixed-tool calls including integer arguments,
deterministic at temperature 0, and factually right on a short QA set — 9/9.
31 tok/s single-stream, 90 tok/s aggregate at concurrency 8.

Two other open PRs load the same checkpoint and should be avoided: #1189 and
#1195 both emit "<|DSML|tool_c天气>" instead of "<|DSML|tool_calls>", so tool
calls silently fail while free text still reads fine. The corruption traces to
the model implementation, not to tokenisation or batching: the input token ids
are identical, both tokenizers decode the marker correctly, and layer-by-layer
hidden-state norms diverge from the published reference by 1.7% at layer 0,
growing past 20% from layer 5 as the MoE router starts selecting different
experts.

Pin model load and generation to one thread
-------------------------------------------

Serving this model surfaced a threading bug that made every request fail with
HTTP 500, so the fix is included here to keep this branch runnable.

MLX streams exist only in the thread that created them, and an array with
pending primitives carries the stream those primitives were built on, so a
model loaded on one thread cannot be driven from another. SimpleEngine spread
that work over three threads: prepare_for_start() and the streaming routes ran
on the event loop, while _run_blocking_serialized() hopped to asyncio.to_thread.
It held together only while all three agreed; once a request took a different
route than the one that built the prompt cache, generation died in mx.eval with
"There is no Stream(gpu, N) in current thread".

The scattered bind_generation_streams() calls cannot fix this: rebinding a
module-level handle changes a global the existing buffers never consult. I
measured all three candidates on a cache built on one thread and evaluated on
another — per-worker rebinding failed 3/3, keeping mlx-lm's import-time
ThreadLocalStream failed 3/3, pinning passed 0/3.

- _generation_worker(), one ThreadPoolExecutor(max_workers=1) per engine, as
  BatchedEngine already does in engine_core.py
- model load moved onto it, in start() and in the residency manager, which had
  its own load path holding prepare_for_start on the event loop thread
- both streaming routes pumped through it with a _STREAM_DONE sentinel, since
  StopIteration cannot cross a thread boundary; generator close() too
- bind_generation_streams() docstring corrected

Generation no longer blocks the event loop, so genuinely concurrent requests
now reach admission control where "fail_fast" rejects them; that was previously
masked. The default is unchanged — operators wanting queuing over 503s set
VLLM_MLX_SIMPLE_ENGINE_LOCK_ADMISSION=wait.

Submitted standalone against main as waybarrios#679 as well, since it is independent of
DeepSeek support.

Verified on DeepSeek-V4-Flash-0731 (283.8B, MXFP4, M3 Ultra): 30/30 non-stream
requests and 18/18 streaming requests pass with 0 stream errors, tool calls
parse 6/6 non-stream and 3/3 streaming, streaming is genuinely incremental
(81 reasoning + 39 content chunks on one response), and decode holds at
30.4 tok/s.

Make continuous batching and the prefix cache work
--------------------------------------------------

Both were off for this model. Turning them on exposed two more bugs, fixed here.

Batching looked like a scheduler hang — running=1, the step counter into the
millions, not one token out. It was not the scheduler: batch_generator.next()
raised "There is no Stream(gpu, N) in current thread", engine_core fell back to
model-thread stepping, hit the same error there, and since that fallback fires
only once the loop then spun on the error. BatchedEngine created its own
engine-core thread but loaded the model on the event loop thread, so the two
never matched. It now owns a persistent single-thread executor, loads on it,
and hands it to EngineCore via generation_worker=.

The prefix cache could never hit. The scheduler stored one entry keyed by
prompt+output, and every later query is shorter than that key, so reuse needs
the generated tail trimmed away. That is impossible here: RotatingKVCache stops
being trimmable once the 128-token sliding window wraps (older KV is physically
overwritten) and PoolingCache cannot split a pooled window (compress_ratios
alternate 4 and 128). Zero hits were guaranteed by construction.

So a second entry is now stored from a snapshot taken while the cache still
covers exactly the prompt, keyed by the prompt tokens, and reusable by prefix
match with no trimming at all. mlx-lm only attaches prompt_cache to the
response carrying a finish_reason, so the per-sequence cache is pulled from the
live batch via extract_cache(idx); the snapshot is a real copy because
RotatingKVCache and PoolingCache both write into their buffers in place.

Exact matches are deliberately skipped. The scheduler re-feeds prompt[-1:] on
those, which duplicates that token in the KV cache when the entry covers the
whole key — measured as the same prompt returning a different answer.

Verified on DeepSeek-V4-Flash-0731 (283.8B, MXFP4, M3 Ultra): 10/10
character-identical answers cold vs warm across 5 scenarios, 30/30 non-stream
and 18/18 streaming requests, 6/6 and 3/3 tool calls, 4/4 concurrent requests
at 54.3 tok/s aggregate versus ~31 single-stream, and 4.19s -> 1.09s on a long
shared prefix. Repo suite: 2412 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
janhilgard added a commit to janhilgard/vllm-mlx that referenced this pull request Aug 3, 2026
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#1192. 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.
Model support comes from mlx-lm PR #1192, verified against this branch on a
DeepSeek-V4-Flash-0731 MXFP4 checkpoint (283.8 B params, M3 Ultra): correct DSML
for single, parallel and mixed-tool calls including integer arguments,
deterministic at temperature 0, and factually right on a short QA set — 9/9.
31 tok/s single-stream, 90 tok/s aggregate at concurrency 8.

Two other open PRs load the same checkpoint and should be avoided: #1189 and
#1195 both emit "<|DSML|tool_c天气>" instead of "<|DSML|tool_calls>", so tool
calls silently fail while free text still reads fine. The corruption traces to
the model implementation, not to tokenisation or batching: the input token ids
are identical, both tokenizers decode the marker correctly, and layer-by-layer
hidden-state norms diverge from the published reference by 1.7% at layer 0,
growing past 20% from layer 5 as the MoE router starts selecting different
experts.

Pin model load and generation to one thread
-------------------------------------------

Serving this model surfaced a threading bug that made every request fail with
HTTP 500, so the fix is included here to keep this branch runnable.

MLX streams exist only in the thread that created them, and an array with
pending primitives carries the stream those primitives were built on, so a
model loaded on one thread cannot be driven from another. SimpleEngine spread
that work over three threads: prepare_for_start() and the streaming routes ran
on the event loop, while _run_blocking_serialized() hopped to asyncio.to_thread.
It held together only while all three agreed; once a request took a different
route than the one that built the prompt cache, generation died in mx.eval with
"There is no Stream(gpu, N) in current thread".

The scattered bind_generation_streams() calls cannot fix this: rebinding a
module-level handle changes a global the existing buffers never consult. I
measured all three candidates on a cache built on one thread and evaluated on
another — per-worker rebinding failed 3/3, keeping mlx-lm's import-time
ThreadLocalStream failed 3/3, pinning passed 0/3.

- _generation_worker(), one ThreadPoolExecutor(max_workers=1) per engine, as
  BatchedEngine already does in engine_core.py
- model load moved onto it, in start() and in the residency manager, which had
  its own load path holding prepare_for_start on the event loop thread
- both streaming routes pumped through it with a _STREAM_DONE sentinel, since
  StopIteration cannot cross a thread boundary; generator close() too
- bind_generation_streams() docstring corrected

Generation no longer blocks the event loop, so genuinely concurrent requests
now reach admission control where "fail_fast" rejects them; that was previously
masked. The default is unchanged — operators wanting queuing over 503s set
VLLM_MLX_SIMPLE_ENGINE_LOCK_ADMISSION=wait.

Submitted standalone against main as waybarrios#679 as well, since it is independent of
DeepSeek support.

Verified on DeepSeek-V4-Flash-0731 (283.8B, MXFP4, M3 Ultra): 30/30 non-stream
requests and 18/18 streaming requests pass with 0 stream errors, tool calls
parse 6/6 non-stream and 3/3 streaming, streaming is genuinely incremental
(81 reasoning + 39 content chunks on one response), and decode holds at
30.4 tok/s.

Make continuous batching and the prefix cache work
--------------------------------------------------

Both were off for this model. Turning them on exposed two more bugs, fixed here.

Batching looked like a scheduler hang — running=1, the step counter into the
millions, not one token out. It was not the scheduler: batch_generator.next()
raised "There is no Stream(gpu, N) in current thread", engine_core fell back to
model-thread stepping, hit the same error there, and since that fallback fires
only once the loop then spun on the error. BatchedEngine created its own
engine-core thread but loaded the model on the event loop thread, so the two
never matched. It now owns a persistent single-thread executor, loads on it,
and hands it to EngineCore via generation_worker=.

The prefix cache could never hit. The scheduler stored one entry keyed by
prompt+output, and every later query is shorter than that key, so reuse needs
the generated tail trimmed away. That is impossible here: RotatingKVCache stops
being trimmable once the 128-token sliding window wraps (older KV is physically
overwritten) and PoolingCache cannot split a pooled window (compress_ratios
alternate 4 and 128). Zero hits were guaranteed by construction.

So a second entry is now stored from a snapshot taken while the cache still
covers exactly the prompt, keyed by the prompt tokens, and reusable by prefix
match with no trimming at all. mlx-lm only attaches prompt_cache to the
response carrying a finish_reason, so the per-sequence cache is pulled from the
live batch via extract_cache(idx); the snapshot is a real copy because
RotatingKVCache and PoolingCache both write into their buffers in place.

Exact matches are deliberately skipped. The scheduler re-feeds prompt[-1:] on
those, which duplicates that token in the KV cache when the entry covers the
whole key — measured as the same prompt returning a different answer.

Verified on DeepSeek-V4-Flash-0731 (283.8B, MXFP4, M3 Ultra): 10/10
character-identical answers cold vs warm across 5 scenarios, 30/30 non-stream
and 18/18 streaming requests, 6/6 and 3/3 tool calls, 4/4 concurrent requests
at 54.3 tok/s aggregate versus ~31 single-stream, and 4.19s -> 1.09s on a long
shared prefix. Repo suite: 2412 passed.

Honour --chunked-prefill-tokens on current mlx-lm
--------------------------------------------------

The flag was silently dropped: the scheduler only applied it by monkey-patching
BatchGenerator._process_prompts/active_batch, and this mlx-lm no longer has
either, so it logged "Chunked prefill disabled" and prefilled in one go. That
warning was also wrong about the consequence — mlx-lm chunks the prompt itself,
PromptProcessingBatch consumes at most prefill_step_size tokens per step, so the
budget just needs to be mapped onto that. Only the patch's extra, mid-prefill
cache saves, is genuinely unavailable, and the log now says so.

Measured with an 8413-token prefill and a short request arriving 1s later:
1024 tokens/chunk gave short 27.3s / long 29.0s; 256 gave short 12.1s / long
30.6s. So the granularity really is the lever for head-of-line latency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
janhilgard added a commit to janhilgard/vllm-mlx that referenced this pull request Aug 3, 2026
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#1192. 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.
Model support comes from mlx-lm PR #1192, verified against this branch on a
DeepSeek-V4-Flash-0731 MXFP4 checkpoint (283.8 B params, M3 Ultra): correct DSML
for single, parallel and mixed-tool calls including integer arguments,
deterministic at temperature 0, and factually right on a short QA set — 9/9.
31 tok/s single-stream, 90 tok/s aggregate at concurrency 8.

Two other open PRs load the same checkpoint and should be avoided: #1189 and
#1195 both emit "<|DSML|tool_c天气>" instead of "<|DSML|tool_calls>", so tool
calls silently fail while free text still reads fine. The corruption traces to
the model implementation, not to tokenisation or batching: the input token ids
are identical, both tokenizers decode the marker correctly, and layer-by-layer
hidden-state norms diverge from the published reference by 1.7% at layer 0,
growing past 20% from layer 5 as the MoE router starts selecting different
experts.

Pin model load and generation to one thread
-------------------------------------------

Serving this model surfaced a threading bug that made every request fail with
HTTP 500, so the fix is included here to keep this branch runnable.

MLX streams exist only in the thread that created them, and an array with
pending primitives carries the stream those primitives were built on, so a
model loaded on one thread cannot be driven from another. SimpleEngine spread
that work over three threads: prepare_for_start() and the streaming routes ran
on the event loop, while _run_blocking_serialized() hopped to asyncio.to_thread.
It held together only while all three agreed; once a request took a different
route than the one that built the prompt cache, generation died in mx.eval with
"There is no Stream(gpu, N) in current thread".

The scattered bind_generation_streams() calls cannot fix this: rebinding a
module-level handle changes a global the existing buffers never consult. I
measured all three candidates on a cache built on one thread and evaluated on
another — per-worker rebinding failed 3/3, keeping mlx-lm's import-time
ThreadLocalStream failed 3/3, pinning passed 0/3.

- _generation_worker(), one ThreadPoolExecutor(max_workers=1) per engine, as
  BatchedEngine already does in engine_core.py
- model load moved onto it, in start() and in the residency manager, which had
  its own load path holding prepare_for_start on the event loop thread
- both streaming routes pumped through it with a _STREAM_DONE sentinel, since
  StopIteration cannot cross a thread boundary; generator close() too
- bind_generation_streams() docstring corrected

Generation no longer blocks the event loop, so genuinely concurrent requests
now reach admission control where "fail_fast" rejects them; that was previously
masked. The default is unchanged — operators wanting queuing over 503s set
VLLM_MLX_SIMPLE_ENGINE_LOCK_ADMISSION=wait.

Submitted standalone against main as waybarrios#679 as well, since it is independent of
DeepSeek support.

Verified on DeepSeek-V4-Flash-0731 (283.8B, MXFP4, M3 Ultra): 30/30 non-stream
requests and 18/18 streaming requests pass with 0 stream errors, tool calls
parse 6/6 non-stream and 3/3 streaming, streaming is genuinely incremental
(81 reasoning + 39 content chunks on one response), and decode holds at
30.4 tok/s.

Make continuous batching and the prefix cache work
--------------------------------------------------

Both were off for this model. Turning them on exposed two more bugs, fixed here.

Batching looked like a scheduler hang — running=1, the step counter into the
millions, not one token out. It was not the scheduler: batch_generator.next()
raised "There is no Stream(gpu, N) in current thread", engine_core fell back to
model-thread stepping, hit the same error there, and since that fallback fires
only once the loop then spun on the error. BatchedEngine created its own
engine-core thread but loaded the model on the event loop thread, so the two
never matched. It now owns a persistent single-thread executor, loads on it,
and hands it to EngineCore via generation_worker=.

The prefix cache could never hit. The scheduler stored one entry keyed by
prompt+output, and every later query is shorter than that key, so reuse needs
the generated tail trimmed away. That is impossible here: RotatingKVCache stops
being trimmable once the 128-token sliding window wraps (older KV is physically
overwritten) and PoolingCache cannot split a pooled window (compress_ratios
alternate 4 and 128). Zero hits were guaranteed by construction.

So a second entry is now stored from a snapshot taken while the cache still
covers exactly the prompt, keyed by the prompt tokens, and reusable by prefix
match with no trimming at all. mlx-lm only attaches prompt_cache to the
response carrying a finish_reason, so the per-sequence cache is pulled from the
live batch via extract_cache(idx); the snapshot is a real copy because
RotatingKVCache and PoolingCache both write into their buffers in place.

Exact matches are deliberately skipped. The scheduler re-feeds prompt[-1:] on
those, which duplicates that token in the KV cache when the entry covers the
whole key — measured as the same prompt returning a different answer.

Verified on DeepSeek-V4-Flash-0731 (283.8B, MXFP4, M3 Ultra): 10/10
character-identical answers cold vs warm across 5 scenarios, 30/30 non-stream
and 18/18 streaming requests, 6/6 and 3/3 tool calls, 4/4 concurrent requests
at 54.3 tok/s aggregate versus ~31 single-stream, and 4.19s -> 1.09s on a long
shared prefix. Repo suite: 2412 passed.

Honour --chunked-prefill-tokens on current mlx-lm
--------------------------------------------------

The flag was silently dropped: the scheduler only applied it by monkey-patching
BatchGenerator._process_prompts/active_batch, and this mlx-lm no longer has
either, so it logged "Chunked prefill disabled" and prefilled in one go. That
warning was also wrong about the consequence — mlx-lm chunks the prompt itself,
PromptProcessingBatch consumes at most prefill_step_size tokens per step, so the
budget just needs to be mapped onto that. Only the patch's extra, mid-prefill
cache saves, is genuinely unavailable, and the log now says so.

Measured with an 8413-token prefill and a short request arriving 1s later:
1024 tokens/chunk gave short 27.3s / long 29.0s; 256 gave short 12.1s / long
30.6s. So the granularity really is the lever for head-of-line latency.

Stop the prefix cache exhausting Metal buffers
-----------------------------------------------

Under a real agentic client the post-prefill snapshot was stored with
evict_prefixes=False, so every turn added another full-length KV copy instead
of replacing the one it supersedes: 45 entries of a ~46k-token cache. Each copy
is hundreds of arrays (43 layers, CacheList of three caches each), and Metal
runs out of *buffers* long before the cache's byte budget is reached —
"[metal::malloc] Resource limit (499000) exceeded" raised inside the model,
which aborted the running request and looked like a hang to the client.

Two changes:

- store the snapshot with evict_prefixes=True, so an agentic conversation keeps
  one entry instead of one per turn
- skip the completion-time prompt+output entry when the cache cannot be
  trimmed. Such an entry can never be reused (every later query is shorter than
  its key), so it was pure memory pressure; gated on can_trim_prompt_cache.

Verified with an 8-turn tool-calling conversation over a ~33k-token context:
7/8 turns hit the cache with 84 tokens prefilled instead of 33k, entry count
stayed at 1, tool calls parsed 8/8, zero Metal errors, and steady-state turn
latency dropped from ~150s to 3.7-5.2s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
janhilgard added a commit to janhilgard/vllm-mlx that referenced this pull request Aug 3, 2026
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#1192. 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.
Model support comes from mlx-lm PR #1192, verified against this branch on a
DeepSeek-V4-Flash-0731 MXFP4 checkpoint (283.8 B params, M3 Ultra): correct DSML
for single, parallel and mixed-tool calls including integer arguments,
deterministic at temperature 0, and factually right on a short QA set — 9/9.
31 tok/s single-stream, 90 tok/s aggregate at concurrency 8.

Two other open PRs load the same checkpoint and should be avoided: #1189 and
#1195 both emit "<|DSML|tool_c天气>" instead of "<|DSML|tool_calls>", so tool
calls silently fail while free text still reads fine. The corruption traces to
the model implementation, not to tokenisation or batching: the input token ids
are identical, both tokenizers decode the marker correctly, and layer-by-layer
hidden-state norms diverge from the published reference by 1.7% at layer 0,
growing past 20% from layer 5 as the MoE router starts selecting different
experts.

Pin model load and generation to one thread
-------------------------------------------

Serving this model surfaced a threading bug that made every request fail with
HTTP 500, so the fix is included here to keep this branch runnable.

MLX streams exist only in the thread that created them, and an array with
pending primitives carries the stream those primitives were built on, so a
model loaded on one thread cannot be driven from another. SimpleEngine spread
that work over three threads: prepare_for_start() and the streaming routes ran
on the event loop, while _run_blocking_serialized() hopped to asyncio.to_thread.
It held together only while all three agreed; once a request took a different
route than the one that built the prompt cache, generation died in mx.eval with
"There is no Stream(gpu, N) in current thread".

The scattered bind_generation_streams() calls cannot fix this: rebinding a
module-level handle changes a global the existing buffers never consult. I
measured all three candidates on a cache built on one thread and evaluated on
another — per-worker rebinding failed 3/3, keeping mlx-lm's import-time
ThreadLocalStream failed 3/3, pinning passed 0/3.

- _generation_worker(), one ThreadPoolExecutor(max_workers=1) per engine, as
  BatchedEngine already does in engine_core.py
- model load moved onto it, in start() and in the residency manager, which had
  its own load path holding prepare_for_start on the event loop thread
- both streaming routes pumped through it with a _STREAM_DONE sentinel, since
  StopIteration cannot cross a thread boundary; generator close() too
- bind_generation_streams() docstring corrected

Generation no longer blocks the event loop, so genuinely concurrent requests
now reach admission control where "fail_fast" rejects them; that was previously
masked. The default is unchanged — operators wanting queuing over 503s set
VLLM_MLX_SIMPLE_ENGINE_LOCK_ADMISSION=wait.

Submitted standalone against main as waybarrios#679 as well, since it is independent of
DeepSeek support.

Verified on DeepSeek-V4-Flash-0731 (283.8B, MXFP4, M3 Ultra): 30/30 non-stream
requests and 18/18 streaming requests pass with 0 stream errors, tool calls
parse 6/6 non-stream and 3/3 streaming, streaming is genuinely incremental
(81 reasoning + 39 content chunks on one response), and decode holds at
30.4 tok/s.

Make continuous batching and the prefix cache work
--------------------------------------------------

Both were off for this model. Turning them on exposed two more bugs, fixed here.

Batching looked like a scheduler hang — running=1, the step counter into the
millions, not one token out. It was not the scheduler: batch_generator.next()
raised "There is no Stream(gpu, N) in current thread", engine_core fell back to
model-thread stepping, hit the same error there, and since that fallback fires
only once the loop then spun on the error. BatchedEngine created its own
engine-core thread but loaded the model on the event loop thread, so the two
never matched. It now owns a persistent single-thread executor, loads on it,
and hands it to EngineCore via generation_worker=.

The prefix cache could never hit. The scheduler stored one entry keyed by
prompt+output, and every later query is shorter than that key, so reuse needs
the generated tail trimmed away. That is impossible here: RotatingKVCache stops
being trimmable once the 128-token sliding window wraps (older KV is physically
overwritten) and PoolingCache cannot split a pooled window (compress_ratios
alternate 4 and 128). Zero hits were guaranteed by construction.

So a second entry is now stored from a snapshot taken while the cache still
covers exactly the prompt, keyed by the prompt tokens, and reusable by prefix
match with no trimming at all. mlx-lm only attaches prompt_cache to the
response carrying a finish_reason, so the per-sequence cache is pulled from the
live batch via extract_cache(idx); the snapshot is a real copy because
RotatingKVCache and PoolingCache both write into their buffers in place.

Exact matches are deliberately skipped. The scheduler re-feeds prompt[-1:] on
those, which duplicates that token in the KV cache when the entry covers the
whole key — measured as the same prompt returning a different answer.

Verified on DeepSeek-V4-Flash-0731 (283.8B, MXFP4, M3 Ultra): 10/10
character-identical answers cold vs warm across 5 scenarios, 30/30 non-stream
and 18/18 streaming requests, 6/6 and 3/3 tool calls, 4/4 concurrent requests
at 54.3 tok/s aggregate versus ~31 single-stream, and 4.19s -> 1.09s on a long
shared prefix. Repo suite: 2412 passed.

Honour --chunked-prefill-tokens on current mlx-lm
--------------------------------------------------

The flag was silently dropped: the scheduler only applied it by monkey-patching
BatchGenerator._process_prompts/active_batch, and this mlx-lm no longer has
either, so it logged "Chunked prefill disabled" and prefilled in one go. That
warning was also wrong about the consequence — mlx-lm chunks the prompt itself,
PromptProcessingBatch consumes at most prefill_step_size tokens per step, so the
budget just needs to be mapped onto that. Only the patch's extra, mid-prefill
cache saves, is genuinely unavailable, and the log now says so.

Measured with an 8413-token prefill and a short request arriving 1s later:
1024 tokens/chunk gave short 27.3s / long 29.0s; 256 gave short 12.1s / long
30.6s. So the granularity really is the lever for head-of-line latency.

Stop the prefix cache exhausting Metal buffers
-----------------------------------------------

Under a real agentic client the post-prefill snapshot was stored with
evict_prefixes=False, so every turn added another full-length KV copy instead
of replacing the one it supersedes: 45 entries of a ~46k-token cache. Each copy
is hundreds of arrays (43 layers, CacheList of three caches each), and Metal
runs out of *buffers* long before the cache's byte budget is reached —
"[metal::malloc] Resource limit (499000) exceeded" raised inside the model,
which aborted the running request and looked like a hang to the client.

Two changes:

- store the snapshot with evict_prefixes=True, so an agentic conversation keeps
  one entry instead of one per turn
- skip the completion-time prompt+output entry when the cache cannot be
  trimmed. Such an entry can never be reused (every later query is shorter than
  its key), so it was pure memory pressure; gated on can_trim_prompt_cache.

Also skip refreshing a snapshot when an existing entry already covers all but
SNAPSHOT_REFRESH_TOKENS of the prompt. The older entry still yields a prefix hit
next turn, only a few tokens shorter, so the refresh buys almost nothing while
allocating a fresh set of per-layer arrays every turn — and buffer count, not
bytes, is the resource that runs out. The copy itself is cheap: instrumented at
make/copy/eval = 0.00/0.00/0.01s for a 43-layer, 11k-token cache.

Verified with an 8-turn tool-calling conversation over a ~33k-token context:
7/8 turns hit the cache with 84 tokens prefilled instead of 33k, entry count
stayed at 1, tool calls parsed 8/8, zero Metal errors, and steady-state turn
latency dropped from ~150s to 3.7-5.2s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
janhilgard added a commit to janhilgard/vllm-mlx that referenced this pull request Aug 3, 2026
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#1192. 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.
Model support comes from mlx-lm PR #1192, verified against this branch on a
DeepSeek-V4-Flash-0731 MXFP4 checkpoint (283.8 B params, M3 Ultra): correct DSML
for single, parallel and mixed-tool calls including integer arguments,
deterministic at temperature 0, and factually right on a short QA set — 9/9.
31 tok/s single-stream, 90 tok/s aggregate at concurrency 8.

Two other open PRs load the same checkpoint and should be avoided: #1189 and
#1195 both emit "<|DSML|tool_c天气>" instead of "<|DSML|tool_calls>", so tool
calls silently fail while free text still reads fine. The corruption traces to
the model implementation, not to tokenisation or batching: the input token ids
are identical, both tokenizers decode the marker correctly, and layer-by-layer
hidden-state norms diverge from the published reference by 1.7% at layer 0,
growing past 20% from layer 5 as the MoE router starts selecting different
experts.

Pin model load and generation to one thread
-------------------------------------------

Serving this model surfaced a threading bug that made every request fail with
HTTP 500, so the fix is included here to keep this branch runnable.

MLX streams exist only in the thread that created them, and an array with
pending primitives carries the stream those primitives were built on, so a
model loaded on one thread cannot be driven from another. SimpleEngine spread
that work over three threads: prepare_for_start() and the streaming routes ran
on the event loop, while _run_blocking_serialized() hopped to asyncio.to_thread.
It held together only while all three agreed; once a request took a different
route than the one that built the prompt cache, generation died in mx.eval with
"There is no Stream(gpu, N) in current thread".

The scattered bind_generation_streams() calls cannot fix this: rebinding a
module-level handle changes a global the existing buffers never consult. I
measured all three candidates on a cache built on one thread and evaluated on
another — per-worker rebinding failed 3/3, keeping mlx-lm's import-time
ThreadLocalStream failed 3/3, pinning passed 0/3.

- _generation_worker(), one ThreadPoolExecutor(max_workers=1) per engine, as
  BatchedEngine already does in engine_core.py
- model load moved onto it, in start() and in the residency manager, which had
  its own load path holding prepare_for_start on the event loop thread
- both streaming routes pumped through it with a _STREAM_DONE sentinel, since
  StopIteration cannot cross a thread boundary; generator close() too
- bind_generation_streams() docstring corrected

Generation no longer blocks the event loop, so genuinely concurrent requests
now reach admission control where "fail_fast" rejects them; that was previously
masked. The default is unchanged — operators wanting queuing over 503s set
VLLM_MLX_SIMPLE_ENGINE_LOCK_ADMISSION=wait.

Submitted standalone against main as waybarrios#679 as well, since it is independent of
DeepSeek support.

Verified on DeepSeek-V4-Flash-0731 (283.8B, MXFP4, M3 Ultra): 30/30 non-stream
requests and 18/18 streaming requests pass with 0 stream errors, tool calls
parse 6/6 non-stream and 3/3 streaming, streaming is genuinely incremental
(81 reasoning + 39 content chunks on one response), and decode holds at
30.4 tok/s.

Make continuous batching and the prefix cache work
--------------------------------------------------

Both were off for this model. Turning them on exposed two more bugs, fixed here.

Batching looked like a scheduler hang — running=1, the step counter into the
millions, not one token out. It was not the scheduler: batch_generator.next()
raised "There is no Stream(gpu, N) in current thread", engine_core fell back to
model-thread stepping, hit the same error there, and since that fallback fires
only once the loop then spun on the error. BatchedEngine created its own
engine-core thread but loaded the model on the event loop thread, so the two
never matched. It now owns a persistent single-thread executor, loads on it,
and hands it to EngineCore via generation_worker=.

The prefix cache could never hit. The scheduler stored one entry keyed by
prompt+output, and every later query is shorter than that key, so reuse needs
the generated tail trimmed away. That is impossible here: RotatingKVCache stops
being trimmable once the 128-token sliding window wraps (older KV is physically
overwritten) and PoolingCache cannot split a pooled window (compress_ratios
alternate 4 and 128). Zero hits were guaranteed by construction.

So a second entry is now stored from a snapshot taken while the cache still
covers exactly the prompt, keyed by the prompt tokens, and reusable by prefix
match with no trimming at all. mlx-lm only attaches prompt_cache to the
response carrying a finish_reason, so the per-sequence cache is pulled from the
live batch via extract_cache(idx); the snapshot is a real copy because
RotatingKVCache and PoolingCache both write into their buffers in place.

Exact matches are deliberately skipped. The scheduler re-feeds prompt[-1:] on
those, which duplicates that token in the KV cache when the entry covers the
whole key — measured as the same prompt returning a different answer.

Verified on DeepSeek-V4-Flash-0731 (283.8B, MXFP4, M3 Ultra): 10/10
character-identical answers cold vs warm across 5 scenarios, 30/30 non-stream
and 18/18 streaming requests, 6/6 and 3/3 tool calls, 4/4 concurrent requests
at 54.3 tok/s aggregate versus ~31 single-stream, and 4.19s -> 1.09s on a long
shared prefix. Repo suite: 2412 passed.

Honour --chunked-prefill-tokens on current mlx-lm
--------------------------------------------------

The flag was silently dropped: the scheduler only applied it by monkey-patching
BatchGenerator._process_prompts/active_batch, and this mlx-lm no longer has
either, so it logged "Chunked prefill disabled" and prefilled in one go. That
warning was also wrong about the consequence — mlx-lm chunks the prompt itself,
PromptProcessingBatch consumes at most prefill_step_size tokens per step, so the
budget just needs to be mapped onto that. Only the patch's extra, mid-prefill
cache saves, is genuinely unavailable, and the log now says so.

Measured with an 8413-token prefill and a short request arriving 1s later:
1024 tokens/chunk gave short 27.3s / long 29.0s; 256 gave short 12.1s / long
30.6s. So the granularity really is the lever for head-of-line latency.

Stop the prefix cache exhausting Metal buffers
-----------------------------------------------

Under a real agentic client the post-prefill snapshot was stored with
evict_prefixes=False, so every turn added another full-length KV copy instead
of replacing the one it supersedes: 45 entries of a ~46k-token cache. Each copy
is hundreds of arrays (43 layers, CacheList of three caches each), and Metal
runs out of *buffers* long before the cache's byte budget is reached —
"[metal::malloc] Resource limit (499000) exceeded" raised inside the model,
which aborted the running request and looked like a hang to the client.

Two changes:

- store the snapshot with evict_prefixes=True, so an agentic conversation keeps
  one entry instead of one per turn
- skip the completion-time prompt+output entry when the cache cannot be
  trimmed. Such an entry can never be reused (every later query is shorter than
  its key), so it was pure memory pressure; gated on can_trim_prompt_cache.

Also skip refreshing a snapshot when an existing entry already covers all but
SNAPSHOT_REFRESH_TOKENS of the prompt. The older entry still yields a prefix hit
next turn, only a few tokens shorter, so the refresh buys almost nothing while
allocating a fresh set of per-layer arrays every turn — and buffer count, not
bytes, is the resource that runs out. The copy itself is cheap: instrumented at
make/copy/eval = 0.00/0.00/0.01s for a 43-layer, 11k-token cache.

Verified with an 8-turn tool-calling conversation over a ~33k-token context:
7/8 turns hit the cache with 84 tokens prefilled instead of 33k, entry count
stayed at 1, tool calls parsed 8/8, zero Metal errors, and steady-state turn
latency dropped from ~150s to 3.7-5.2s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
janhilgard added a commit to janhilgard/vllm-mlx that referenced this pull request Aug 3, 2026
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#1192. 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.
Model support comes from mlx-lm PR #1192, verified against this branch on a
DeepSeek-V4-Flash-0731 MXFP4 checkpoint (283.8 B params, M3 Ultra): correct DSML
for single, parallel and mixed-tool calls including integer arguments,
deterministic at temperature 0, and factually right on a short QA set — 9/9.
31 tok/s single-stream, 90 tok/s aggregate at concurrency 8.

Two other open PRs load the same checkpoint and should be avoided: #1189 and
#1195 both emit "<|DSML|tool_c天气>" instead of "<|DSML|tool_calls>", so tool
calls silently fail while free text still reads fine. The corruption traces to
the model implementation, not to tokenisation or batching: the input token ids
are identical, both tokenizers decode the marker correctly, and layer-by-layer
hidden-state norms diverge from the published reference by 1.7% at layer 0,
growing past 20% from layer 5 as the MoE router starts selecting different
experts.

Pin model load and generation to one thread
-------------------------------------------

Serving this model surfaced a threading bug that made every request fail with
HTTP 500, so the fix is included here to keep this branch runnable.

MLX streams exist only in the thread that created them, and an array with
pending primitives carries the stream those primitives were built on, so a
model loaded on one thread cannot be driven from another. SimpleEngine spread
that work over three threads: prepare_for_start() and the streaming routes ran
on the event loop, while _run_blocking_serialized() hopped to asyncio.to_thread.
It held together only while all three agreed; once a request took a different
route than the one that built the prompt cache, generation died in mx.eval with
"There is no Stream(gpu, N) in current thread".

The scattered bind_generation_streams() calls cannot fix this: rebinding a
module-level handle changes a global the existing buffers never consult. I
measured all three candidates on a cache built on one thread and evaluated on
another — per-worker rebinding failed 3/3, keeping mlx-lm's import-time
ThreadLocalStream failed 3/3, pinning passed 0/3.

- _generation_worker(), one ThreadPoolExecutor(max_workers=1) per engine, as
  BatchedEngine already does in engine_core.py
- model load moved onto it, in start() and in the residency manager, which had
  its own load path holding prepare_for_start on the event loop thread
- both streaming routes pumped through it with a _STREAM_DONE sentinel, since
  StopIteration cannot cross a thread boundary; generator close() too
- bind_generation_streams() docstring corrected

Generation no longer blocks the event loop, so genuinely concurrent requests
now reach admission control where "fail_fast" rejects them; that was previously
masked. The default is unchanged — operators wanting queuing over 503s set
VLLM_MLX_SIMPLE_ENGINE_LOCK_ADMISSION=wait.

Submitted standalone against main as waybarrios#679 as well, since it is independent of
DeepSeek support.

Verified on DeepSeek-V4-Flash-0731 (283.8B, MXFP4, M3 Ultra): 30/30 non-stream
requests and 18/18 streaming requests pass with 0 stream errors, tool calls
parse 6/6 non-stream and 3/3 streaming, streaming is genuinely incremental
(81 reasoning + 39 content chunks on one response), and decode holds at
30.4 tok/s.

Make continuous batching and the prefix cache work
--------------------------------------------------

Both were off for this model. Turning them on exposed two more bugs, fixed here.

Batching looked like a scheduler hang — running=1, the step counter into the
millions, not one token out. It was not the scheduler: batch_generator.next()
raised "There is no Stream(gpu, N) in current thread", engine_core fell back to
model-thread stepping, hit the same error there, and since that fallback fires
only once the loop then spun on the error. BatchedEngine created its own
engine-core thread but loaded the model on the event loop thread, so the two
never matched. It now owns a persistent single-thread executor, loads on it,
and hands it to EngineCore via generation_worker=.

The prefix cache could never hit. The scheduler stored one entry keyed by
prompt+output, and every later query is shorter than that key, so reuse needs
the generated tail trimmed away. That is impossible here: RotatingKVCache stops
being trimmable once the 128-token sliding window wraps (older KV is physically
overwritten) and PoolingCache cannot split a pooled window (compress_ratios
alternate 4 and 128). Zero hits were guaranteed by construction.

So a second entry is now stored from a snapshot taken while the cache still
covers exactly the prompt, keyed by the prompt tokens, and reusable by prefix
match with no trimming at all. mlx-lm only attaches prompt_cache to the
response carrying a finish_reason, so the per-sequence cache is pulled from the
live batch via extract_cache(idx); the snapshot is a real copy because
RotatingKVCache and PoolingCache both write into their buffers in place.

Exact matches are deliberately skipped. The scheduler re-feeds prompt[-1:] on
those, which duplicates that token in the KV cache when the entry covers the
whole key — measured as the same prompt returning a different answer.

Verified on DeepSeek-V4-Flash-0731 (283.8B, MXFP4, M3 Ultra): 10/10
character-identical answers cold vs warm across 5 scenarios, 30/30 non-stream
and 18/18 streaming requests, 6/6 and 3/3 tool calls, 4/4 concurrent requests
at 54.3 tok/s aggregate versus ~31 single-stream, and 4.19s -> 1.09s on a long
shared prefix. Repo suite: 2412 passed.

Honour --chunked-prefill-tokens on current mlx-lm
--------------------------------------------------

The flag was silently dropped: the scheduler only applied it by monkey-patching
BatchGenerator._process_prompts/active_batch, and this mlx-lm no longer has
either, so it logged "Chunked prefill disabled" and prefilled in one go. That
warning was also wrong about the consequence — mlx-lm chunks the prompt itself,
PromptProcessingBatch consumes at most prefill_step_size tokens per step, so the
budget just needs to be mapped onto that. Only the patch's extra, mid-prefill
cache saves, is genuinely unavailable, and the log now says so.

Measured with an 8413-token prefill and a short request arriving 1s later:
1024 tokens/chunk gave short 27.3s / long 29.0s; 256 gave short 12.1s / long
30.6s. So the granularity really is the lever for head-of-line latency.

Stop the prefix cache exhausting Metal buffers
-----------------------------------------------

Under a real agentic client the post-prefill snapshot was stored with
evict_prefixes=False, so every turn added another full-length KV copy instead
of replacing the one it supersedes: 45 entries of a ~46k-token cache. Each copy
is hundreds of arrays (43 layers, CacheList of three caches each), and Metal
runs out of *buffers* long before the cache's byte budget is reached —
"[metal::malloc] Resource limit (499000) exceeded" raised inside the model,
which aborted the running request and looked like a hang to the client.

Two changes:

- store the snapshot with evict_prefixes=True, so an agentic conversation keeps
  one entry instead of one per turn
- skip the completion-time prompt+output entry when the cache cannot be
  trimmed. Such an entry can never be reused (every later query is shorter than
  its key), so it was pure memory pressure; gated on can_trim_prompt_cache.

Also skip refreshing a snapshot when an existing entry already covers all but
SNAPSHOT_REFRESH_TOKENS of the prompt. The older entry still yields a prefix hit
next turn, only a few tokens shorter, so the refresh buys almost nothing while
allocating a fresh set of per-layer arrays every turn — and buffer count, not
bytes, is the resource that runs out. The copy itself is cheap: instrumented at
make/copy/eval = 0.00/0.00/0.01s for a 43-layer, 11k-token cache.

Verified with an 8-turn tool-calling conversation over a ~33k-token context:
7/8 turns hit the cache with 84 tokens prefilled instead of 33k, entry count
stayed at 1, tool calls parsed 8/8, zero Metal errors, and steady-state turn
latency dropped from ~150s to 3.7-5.2s.

Measure nested cache states; only throttle snapshot refreshes
-------------------------------------------------------------

estimate_kv_cache_memory only understood a flat (keys, values) state, so
CacheList's three nested sub-states raised ValueError, were swallowed, and the
entry was accounted as zero bytes. The dashboard's Prefix Cache bar sat at 0%
for this model — and, far worse, the byte-based LRU eviction never fired, which
is the other half of the Metal buffer exhaustion fixed above. States are now
walked recursively (shape+dtype only, no lazy eval), with a regression test
matching CacheList(Rotating, Pooling, Pooling) including None members.
Verified live: a 2k-token entry now accounts 22MB and an 11k one 155MB.

The snapshot-refresh throttle also gated the FIRST store of a conversation:
covered==0 fell under the same threshold test, so conversations shorter than
SNAPSHOT_REFRESH_TOKENS were never cached at all. The throttle now applies only
when an existing entry already covers the prompt (covered > 0).

While verifying this I mapped when the prompt-keyed entry can actually be a
strict prefix of the next turn: with tools present, or with reasoning_content
replayed, it is (verified against the encoder token-by-token); in a plain
no-tools chat with thinking dropped the key diverges from the next turn at its
final <think> token, so continuation reuse never applies there — that is a
prompt-format property, not a cache bug. Exact-match replays bump the hit
counter but are deliberately not reused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
janhilgard added a commit to janhilgard/vllm-mlx that referenced this pull request Aug 8, 2026
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#1192. 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.
Model support comes from mlx-lm PR #1192, verified against this branch on a
DeepSeek-V4-Flash-0731 MXFP4 checkpoint (283.8 B params, M3 Ultra): correct DSML
for single, parallel and mixed-tool calls including integer arguments,
deterministic at temperature 0, and factually right on a short QA set — 9/9.
31 tok/s single-stream, 90 tok/s aggregate at concurrency 8.

Two other open PRs load the same checkpoint and should be avoided: #1189 and
calls silently fail while free text still reads fine. The corruption traces to
the model implementation, not to tokenisation or batching: the input token ids
are identical, both tokenizers decode the marker correctly, and layer-by-layer
hidden-state norms diverge from the published reference by 1.7% at layer 0,
growing past 20% from layer 5 as the MoE router starts selecting different
experts.

Pin model load and generation to one thread
-------------------------------------------

Serving this model surfaced a threading bug that made every request fail with
HTTP 500, so the fix is included here to keep this branch runnable.

MLX streams exist only in the thread that created them, and an array with
pending primitives carries the stream those primitives were built on, so a
model loaded on one thread cannot be driven from another. SimpleEngine spread
that work over three threads: prepare_for_start() and the streaming routes ran
on the event loop, while _run_blocking_serialized() hopped to asyncio.to_thread.
It held together only while all three agreed; once a request took a different
route than the one that built the prompt cache, generation died in mx.eval with
"There is no Stream(gpu, N) in current thread".

The scattered bind_generation_streams() calls cannot fix this: rebinding a
module-level handle changes a global the existing buffers never consult. I
measured all three candidates on a cache built on one thread and evaluated on
another — per-worker rebinding failed 3/3, keeping mlx-lm's import-time
ThreadLocalStream failed 3/3, pinning passed 0/3.

- _generation_worker(), one ThreadPoolExecutor(max_workers=1) per engine, as
  BatchedEngine already does in engine_core.py
- model load moved onto it, in start() and in the residency manager, which had
  its own load path holding prepare_for_start on the event loop thread
- both streaming routes pumped through it with a _STREAM_DONE sentinel, since
  StopIteration cannot cross a thread boundary; generator close() too
- bind_generation_streams() docstring corrected

Generation no longer blocks the event loop, so genuinely concurrent requests
now reach admission control where "fail_fast" rejects them; that was previously
masked. The default is unchanged — operators wanting queuing over 503s set
VLLM_MLX_SIMPLE_ENGINE_LOCK_ADMISSION=wait.

Submitted standalone against main as waybarrios#679 as well, since it is independent of
DeepSeek support.

Verified on DeepSeek-V4-Flash-0731 (283.8B, MXFP4, M3 Ultra): 30/30 non-stream
requests and 18/18 streaming requests pass with 0 stream errors, tool calls
parse 6/6 non-stream and 3/3 streaming, streaming is genuinely incremental
(81 reasoning + 39 content chunks on one response), and decode holds at
30.4 tok/s.

Make continuous batching and the prefix cache work
--------------------------------------------------

Both were off for this model. Turning them on exposed two more bugs, fixed here.

Batching looked like a scheduler hang — running=1, the step counter into the
millions, not one token out. It was not the scheduler: batch_generator.next()
raised "There is no Stream(gpu, N) in current thread", engine_core fell back to
model-thread stepping, hit the same error there, and since that fallback fires
only once the loop then spun on the error. BatchedEngine created its own
engine-core thread but loaded the model on the event loop thread, so the two
never matched. It now owns a persistent single-thread executor, loads on it,
and hands it to EngineCore via generation_worker=.

The prefix cache could never hit. The scheduler stored one entry keyed by
prompt+output, and every later query is shorter than that key, so reuse needs
the generated tail trimmed away. That is impossible here: RotatingKVCache stops
being trimmable once the 128-token sliding window wraps (older KV is physically
overwritten) and PoolingCache cannot split a pooled window (compress_ratios
alternate 4 and 128). Zero hits were guaranteed by construction.

So a second entry is now stored from a snapshot taken while the cache still
covers exactly the prompt, keyed by the prompt tokens, and reusable by prefix
match with no trimming at all. mlx-lm only attaches prompt_cache to the
response carrying a finish_reason, so the per-sequence cache is pulled from the
live batch via extract_cache(idx); the snapshot is a real copy because
RotatingKVCache and PoolingCache both write into their buffers in place.

Exact matches are deliberately skipped. The scheduler re-feeds prompt[-1:] on
those, which duplicates that token in the KV cache when the entry covers the
whole key — measured as the same prompt returning a different answer.

Verified on DeepSeek-V4-Flash-0731 (283.8B, MXFP4, M3 Ultra): 10/10
character-identical answers cold vs warm across 5 scenarios, 30/30 non-stream
and 18/18 streaming requests, 6/6 and 3/3 tool calls, 4/4 concurrent requests
at 54.3 tok/s aggregate versus ~31 single-stream, and 4.19s -> 1.09s on a long
shared prefix. Repo suite: 2412 passed.

Honour --chunked-prefill-tokens on current mlx-lm
--------------------------------------------------

The flag was silently dropped: the scheduler only applied it by monkey-patching
BatchGenerator._process_prompts/active_batch, and this mlx-lm no longer has
either, so it logged "Chunked prefill disabled" and prefilled in one go. That
warning was also wrong about the consequence — mlx-lm chunks the prompt itself,
PromptProcessingBatch consumes at most prefill_step_size tokens per step, so the
budget just needs to be mapped onto that. Only the patch's extra, mid-prefill
cache saves, is genuinely unavailable, and the log now says so.

Measured with an 8413-token prefill and a short request arriving 1s later:
1024 tokens/chunk gave short 27.3s / long 29.0s; 256 gave short 12.1s / long
30.6s. So the granularity really is the lever for head-of-line latency.

Stop the prefix cache exhausting Metal buffers
-----------------------------------------------

Under a real agentic client the post-prefill snapshot was stored with
evict_prefixes=False, so every turn added another full-length KV copy instead
of replacing the one it supersedes: 45 entries of a ~46k-token cache. Each copy
is hundreds of arrays (43 layers, CacheList of three caches each), and Metal
runs out of *buffers* long before the cache's byte budget is reached —
"[metal::malloc] Resource limit (499000) exceeded" raised inside the model,
which aborted the running request and looked like a hang to the client.

Two changes:

- store the snapshot with evict_prefixes=True, so an agentic conversation keeps
  one entry instead of one per turn
- skip the completion-time prompt+output entry when the cache cannot be
  trimmed. Such an entry can never be reused (every later query is shorter than
  its key), so it was pure memory pressure; gated on can_trim_prompt_cache.

Also skip refreshing a snapshot when an existing entry already covers all but
SNAPSHOT_REFRESH_TOKENS of the prompt. The older entry still yields a prefix hit
next turn, only a few tokens shorter, so the refresh buys almost nothing while
allocating a fresh set of per-layer arrays every turn — and buffer count, not
bytes, is the resource that runs out. The copy itself is cheap: instrumented at
make/copy/eval = 0.00/0.00/0.01s for a 43-layer, 11k-token cache.

Verified with an 8-turn tool-calling conversation over a ~33k-token context:
7/8 turns hit the cache with 84 tokens prefilled instead of 33k, entry count
stayed at 1, tool calls parsed 8/8, zero Metal errors, and steady-state turn
latency dropped from ~150s to 3.7-5.2s.

Measure nested cache states; only throttle snapshot refreshes
-------------------------------------------------------------

estimate_kv_cache_memory only understood a flat (keys, values) state, so
CacheList's three nested sub-states raised ValueError, were swallowed, and the
entry was accounted as zero bytes. The dashboard's Prefix Cache bar sat at 0%
for this model — and, far worse, the byte-based LRU eviction never fired, which
is the other half of the Metal buffer exhaustion fixed above. States are now
walked recursively (shape+dtype only, no lazy eval), with a regression test
matching CacheList(Rotating, Pooling, Pooling) including None members.
Verified live: a 2k-token entry now accounts 22MB and an 11k one 155MB.

The snapshot-refresh throttle also gated the FIRST store of a conversation:
covered==0 fell under the same threshold test, so conversations shorter than
SNAPSHOT_REFRESH_TOKENS were never cached at all. The throttle now applies only
when an existing entry already covers the prompt (covered > 0).

While verifying this I mapped when the prompt-keyed entry can actually be a
strict prefix of the next turn: with tools present, or with reasoning_content
replayed, it is (verified against the encoder token-by-token); in a plain
no-tools chat with thinking dropped the key diverges from the next turn at its
final <think> token, so continuation reuse never applies there — that is a
prompt-format property, not a cache bug. Exact-match replays bump the hit
counter but are deliberately not reused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.