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


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