Skip to content

DeepSeek-V4-Flash-2bit-DQ on M4 Max: OOM fix, native tool calling, benchmarks, degeneration study - #2

Merged
snagnever merged 23 commits into
mainfrom
deepseek-v4-tool-dsml
May 31, 2026
Merged

DeepSeek-V4-Flash-2bit-DQ on M4 Max: OOM fix, native tool calling, benchmarks, degeneration study#2
snagnever merged 23 commits into
mainfrom
deepseek-v4-tool-dsml

Conversation

@snagnever

Copy link
Copy Markdown
Owner

Makes mlx-community/DeepSeek-V4-Flash-2bit-DQ reliable and well-characterized on a Mac Studio M4 Max 128 GB, and contributes the fixes/findings upstream. 23 commits across four workstreams.

1. Metal OOM fix (the headline runtime bug)

Long generations crashed with [metal::malloc] Resource limit (499000) exceeded at ~11.3K tokens, independent of prompt length, wedging the Metal queue. Root cause: the DeepSeek-V4 attention caches (PoolingCache / RotatingKVCache) build per-decode-step updates with un-detached concat/slice graphs, so every prior step's Metal buffer stays resident (~1/layer/step → 499000/43 ≈ 11.3K). Fix: mx.eval all per-layer cache state once per forward in DeepseekV4Model.__call__. After: clean to 19,989 tokens (was OOM at 11,314), 31.3 tok/s (no regression), 0 OOMs over a 300-request soak.

2. Native tool calling (0 → 98%)

Out of the box mlx_lm.server logged "model does not support tool calling" and dropped the tools array (the conversion ships no tools branch + mlx-lm has no DeepSeek parser). Two root causes fixed:

  • Missing tool template → ported the official DSML chat template + wrote a deepseek_dsml parser.
  • A token-merge bug in marker matching (<tool_call>'s trailing > merges with \n) → prefix-anchored matching.

Result on the 2-bit build: jdhodges 98% (39/40), Veerman 75%, parallel multi-tool 8/8 — native DSML beats a generic Hermes workaround (82%) and unlocks parallel calls. (Corrected an earlier "2-bit can't do parallel tools" conclusion — it was a format tax, not a quant ceiling.)

  • Upstream: parser PR mlx-lm#1337; generic json_tools token-merge fix issue #1335 / PR #1336; pairs with official template PR deepseek-ai/DeepSeek-V4-Flash#16.

3. Benchmark characterization

Knowledge + reasoning soak on this rig: MMLU 44, GPQA 24, HumanEval 48, MATH 47, DROP 71 (+ throughput/T-Bench). Charts + notes updated in the bench submodule.

4. Degeneration sampling study (newest)

Can sampling avoid the 2-bit repetition loop? Rate-measured answer: no — and XTC backfires.

  • The loop is stochastic (~50% on a short trigger; ~94–100% on long-form, judged by an independent Qwen3.6-35B-A3B), not deterministic.
  • No config reliably reduces it; XTC roughly doubles the short-prompt loop rate (37–50% → 87%).
  • Self-correction: an intermediate "XTC fixes it / impossibility result" write-up was an artifact of an mlx-lm server seed bug (#1245/#1331) — the compiled sampler ignored per-request seeds, making all 50 multi-seed groups byte-identical. Ported the fix locally, re-ran with 8 real seeds, conclusions inverted; the section is retracted in the doc.
  • Two upstream bugs surfaced (both already tracked — patches/notes only, no new filings): XTC ragged xtc_special_tokens (#1257/#1258/#1301/#1176) and the seed bug (#1245/#1331).

Notes

  • The OOM fix and the seed/XTC patches live in a gitignored venv; tracked as .patch files + a port note under patches/.
  • Paste-ready HF posts (docs/deepseek-v4-flash-hf-*.md) are staged for the maintainers' threads.
  • Full reproducible harness + raw per-generation data under .bench-logs/ and docs/benchmark-plans/.

🤖 Generated with Claude Code

snagnever and others added 23 commits May 30, 2026 19:03
…l writeup

DeepSeek-V4-Flash on Apple Silicon aborted with `[metal::malloc] Resource
limit (499000) exceeded` after ~11.3K decode tokens (prompt-length
independent), then wedged the server until restart. Root cause: the
attention caches (PoolingCache concat-grow + RotatingKVCache slice-assign,
single and batched) build un-detached per-step lazy graphs, so the cache
pins every prior step's live Metal buffer — the live-resource COUNT hits the
device residency cap (~1 buffer/layer/step, 43 layers ≈ 11.3K steps).

Fix: patches/mlx-lm-deepseek-v4-cache-materialize.patch — one hunk in
DeepseekV4Model.__call__ that mx.eval's all per-layer cache state once per
forward, detaching the chains so the count stays bounded. Verified:
forced-gen 19,989 tokens clean (was 11,314), 31.3 t/s (no regression),
40-case single-server tool-call sweep 40/40 with 0 OOMs (was 49 OOMs,
aborted at case 20). Leak slope 205 → 7 KB/step.

Also: corrected investigation + fix-plan docs (root cause was NOT
per-command-buffer/pooled_seq), setup-guide patch promotion, upstream
issue/PR drafts, and the .bench-logs probes used to diagnose and verify.
Note: the 2-bit DQ quant separately degenerates in open-ended chat and
4-bit+ exceeds 128 GB, so this checkpoint stays benchmark-only on this rig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-bench soak

Submitted the Metal residency-leak fix upstream:
- issue ml-explore/mlx-lm#1332 (root cause + reproducer + diagnostics)
- PR Blaizzy/mlx-lm#25 against the #1192 head branch
- heads-up comment on #1192 linking both

Adds the pre-submission soak result (MMLU+GPQA+HumanEval, 300 requests on a
single long-lived patched server: 0 metal::malloc, 0 errors, ~2h44m) to the
writeup + PR-draft verification tables, plus the soak driver script.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-benches plan

- Phase 3 #10 plan: add "Outcome (2026-05-30) — partial sweep complete" (MMLU 44 /
  GPQA 24 / HumanEval 48, 300-request/0-OOM soak, upstream links); mark retry
  preconditions satisfied.
- New runbook docs/benchmark-plans/2026-05-30-deepseek-v4-flash-remaining-benches.md:
  remaining benches ordered shortest->biggest (Veerman, DROP, throughput, MATH,
  LiveCodeBench, Terminal-Bench) with an explicit "update docs/charts after each"
  protocol and the tool_combined data-hygiene note.
- Bump local-llm-bench-m4-32gb submodule (charts + scoreboards + run data).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… N/A)

Tick Veerman in the remaining-benches runbook; bump submodule (Veerman +
tool_combined chart, missing-tool-template note). DROP/MATH/LiveCodeBench running.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mlx-community 2-bit conversion ships a chat template with no tools branch, so
mlx_lm.server drops the tools array ("model does not support tool calling") and the
model never sees a tool schema -> prose-only on tool benches (jdhodges 8/40, Veerman
2/12, all prose-correct edge cases).

Fix = Hermes/json_tools-style template (DeepSeek <|User|>/<|Assistant|> turn markers
+ <tools> injection + <tool_call> rendering) plus tokenizer_config tool_parser_type=
json_tools, so mlx-lm detects tools, injects schemas, and parses <tool_call> output.

Plumbing verified in-process (temp tokenizer copy, no GPU/server): has_tool_calling
True, tool_call_start '<tool_call>', tools injected into the rendered prompt. Live
model-emission test deferred until the running DROP/MATH/LCB benchmark queue finishes
(install.sh/uninstall.sh manage the model dir; test-...sh runs the probe + Veerman).

Branch only; not merged. See docs/benchmark-plans/2026-05-30-deepseek-v4-flash-tool-template.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…arse (0/4 -> 4/4)

The tool template made the model EMIT perfect <tool_call>{json}</tool_call>, but
mlx-lm's stock json_tools parser never recognized them: it matches the start marker
by token sequence, and "<tool_call>" -> (<,tool,_call,>) while the model emits
"<tool_call>\n" where BPE merges ">"+"\n" into one token (>\n), so the 4-token
marker is never a subsequence and the state machine stays in "normal" -> the call
comes back as content, tool_calls null.

deepseek_json.py matches on the stable "<tool_call" prefix (no ">") and extracts the
first brace-balanced JSON object (tolerating the leftover ">" and trailing
</tool_call>). install.sh deploys it into the venv + sets tool_parser_type.

Verified: 4-prompt probe 0/4 -> 4/4, correct names + args, 0 OOMs. Full jdhodges +
Veerman benches running to quantify vs the 8/40, 2/12 prose floor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… multi-tool tweak reverted

Final tool-calling numbers WITH the template + deepseek_json parser:
  jdhodges 33/40 (82%, was 8/40), Veerman 6/12 (50%, was 2/12), combined 75%, 0 OOMs.

Failure analysis: every miss is reasoning/coverage, not format/parse. Dominant mode is
partial multi-tool (emits 1 of N parallel calls). Tried a template tweak demanding all
parallel calls as consecutive <tool_call> blocks + a 2-block example -> NO improvement
(jdhodges identical 33/40; all 4 parallel cases still 1-of-N; one single-call case
regressed). Parallel multi-tool is a 2-bit capability ceiling, not a template gap.
Reverted to the simpler original template.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…late notes)

Tick MATH in the remaining-benches runbook (47%, 41 TRUNC, 0 OOM). LiveCodeBench
running (last queue item). Submodule -> 045bdbb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tick LCB in the runbook. Knowledge benches all done (MMLU/GPQA/HumanEval/DROP/MATH/LCB),
0 metal::malloc throughout. Remaining: throughput (#3) + Terminal-Bench (#6). Submodule bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…merge bug

Detailed analysis (problem/diagnostic/solution/known-limitations) + a deterministic,
GPU-free reproducer for an mlx-lm bug: tool-call detection matches markers as exact
token-id sequences, but json_tools's "<tool_call>" encodes to a run ending in a
standalone ">" token that BPE merges with the following byte ("<tool_call>\n" -> ">\n"),
so the marker is never a subsequence and valid tool calls are returned as content
(tool_calls=null). Verified via mlx-lm's own SequenceStateMachine. Proposed fix: match
the "<tool_call" prefix + brace-balanced JSON extraction (backward-compatible). Took our
model 0 -> 82% on tool calling. For an issue+PR to ml-explore/mlx-lm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ated before parse)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…indings)

Data point for deepseek-ai/DeepSeek-V4-Flash#16: MLX 2-bit conversion ships no tool
template; tool-aware template -> 82% jdhodges / 50% Veerman; flags the mlx-lm json_tools
token-merge gotcha (#1335/#1336). Native format is DSML, not Hermes; for posting under
the user's HF account.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ule (tool run data)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…the Hermes ceiling)

Native DeepSeek-V4 DSML tool format (official chat_template.jinja from
deepseek-ai/DeepSeek-V4-Flash PR #16 + a deepseek_dsml parser modeled on minimax_m2).
DSML expresses multiple <|DSML|invoke> in one <|DSML|tool_calls> block = native
parallel calls.

Probe result: the 2-bit model emits BOTH parallel calls in DSML
(get_weather Tokyo + get_weather Paris), 0 OOMs — confirming the partial-multi-tool
failure under Hermes was a FORMAT TAX, not a quant ceiling (my earlier conclusion was
too pessimistic). Parser unit tests pass (single/parallel/mixed-type). Marker uses the
stable "<|DSML|tool_calls" prefix to dodge the same ">" BPE merge as json_tools.
Full jdhodges+veerman bench running. thinking OFF via thinking_mode=chat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nclusion; bump submodule

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#1337)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fix + looping = quant)

Answers the open 'repeating output' question on the model repo's discussion #1: separates
the two failure modes (Metal residency-leak crash, fixed + filed upstream #1332/#25; vs the
2-bit quant degeneration loop, inherent), with the diagnosis and links. For the user's HF account.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t loop

Investigate whether sampling can avoid DeepSeek-V4-Flash-2bit-DQ's repetition
degeneration. Outcome vindicates the original "no setting fixes it" — now
quantified with real loop rates.

Findings (rate + LLM-judge validated):
- The loop is stochastic, not deterministic: ~50% on a short open-ended trigger
  (8 seeds), ~94-100% on long-form (story/list, judged by Qwen3.6-35B-A3B).
- No sampling config reliably reduces it; additive penalties land within noise.
- XTC -- the most degeneration-specific knob -- BACKFIRES, ~doubling the short
  loop rate (37-50% -> 87%); it derails the model rather than rescuing it.

Self-correction: an intermediate "XTC fixes it / impossibility result" write-up
was wrong, an artifact of an mlx-lm server bug (#1245/#1331) where the compiled
sampler ignored per-request seeds -- all 50 multi-seed groups were byte-identical,
so each matrix cell was one deterministic sample. Ported the seed fix locally,
verified seeds vary, re-ran with 8 real seeds; conclusions inverted. Section is
retracted in the doc.

Upstream bugs surfaced (both already tracked -- patches/notes only, no new filings):
- XTC ragged xtc_special_tokens crashes server for all models (#1257 / #1258 /
  #1301 / #1176): patches/mlx-lm-xtc-special-tokens-flatten.patch.
- Compiled sampler ignores per-request seed (#1245 / #1331): port documented in
  patches/mlx-lm-server-seed-fix-NOTE.md.

Changes:
- docs/benchmark-plans/2026-05-31-deepseek-v4-flash-degeneration-sampling.md:
  plan + rate tables + judge results + retraction + methodology notes.
- docs/deepseek-v4-flash-hf-leak-post.md sec.2: replace over-absolute claim with
  the rate-backed version (no fix; XTC backfires; long-form ~94-100% collapse).
- .bench-logs/: degeneration sweep harness, coherence judge, runners, raw data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@snagnever
snagnever merged commit 9dabd16 into main May 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant