Skip to content

DeepSeek-V4-Flash: native deepseek_v4 AR backend - #216

Open
davidtai wants to merge 35 commits into
youssofal:mainfrom
davidtai:feat/deepseek-v4-flash
Open

DeepSeek-V4-Flash: native deepseek_v4 AR backend#216
davidtai wants to merge 35 commits into
youssofal:mainfrom
davidtai:feat/deepseek-v4-flash

Conversation

@davidtai

@davidtai davidtai commented Aug 1, 2026

Copy link
Copy Markdown

Adds a native MLX backend for DeepSeek-V4-Flash (model_type: deepseek_v4), loading the published mlx-community/DeepSeek-V4-Flash-4bit / -2bit-DQ checkpoints directly through mtplx serve. No deepseek_v4 loader exists in mlx-lm; this is a from-scratch port of the reference deepseek-ai/DeepSeek-V4-Flash inference code.

What V4 adds over V3.2, and what this implements

  • Hyper-Connections: the residual stream becomes 4 parallel copies mixed through a Sinkhorn-derived doubly-stochastic matrix around every block.
  • Compressed Sparse Attention: layers carry a second, compressed KV cache built by learned gated pooling (overlapping ratio-4 windows with a top-512 indexer, or non-overlapping ratio-128 windows), alongside a 128-token sliding window. Both prefill and single-token streaming decode maintain the compressed lanes incrementally; the indexer's top-k filter engages in both paths once compressed positions exceed index_topk.
  • Grouped output-LoRA on the attention output projection (8 groups, rank 1024).
  • Hash-routed MoE for the first 3 layers (token-id expert lookup instead of scored top-k), sqrtsoftplus scoring elsewhere.

Attention itself is MQA-shaped MLA (64 query heads over one 512-dim latent with per-head sink logits). The decode-side compressor/indexer state machines are adapted from Salvatore Sanfilippo's ds4 engine (MIT), noted inline where copied; everything is expressed in stock MLX ops.

The runtime change is minimal: MTP-declaring checkpoints that ship no draft weights (the mlx-community conversions drop the MTP block) now degrade to AR with a clear message instead of failing at bind, gated on a shard-index probe that fails closed for every existing model.

Verification

  • Reference parity: every new-math component is unit-gated against a transcription of the reference math, and the whole forward is gated layer-by-layer against a golden covering every layer type (final logits max_rel 1.8e-6, argmax 160/160 at the gated config).
  • Decode == prefill: step decode reproduces the one-shot forward across partial windows, chunked prefill, window eviction, compress-window boundary crossings, and batching (max_rel ≤ 4e-6, argmax exact per step). The sparse-regime tests force n_comp > index_topk and cover the dense→sparse crossing mid-decode.
  • Gates are mutation-tested: 18 deliberately injected state-machine and indexer bugs (off-by-one window keep, stale frontiers, dropped Hadamard, wrong tie-break, and so on) each fail the suites.
  • Real weights: on the 2bit-DQ checkpoint (M5 Max), all 2,610 tensors bind with zero missing/extra; greedy generation is coherent at short and 3.3K-token context, and the streamed decode path matches the one-shot argmax 128/128 (dense regime) with the sparse regime's single divergence root-caused to a discrete top-k near-tie rather than a state bug. Repro scripts for these runs are included under scripts/.
  • Suite: 39 new tests; the documented pre-PR selection plus every test file touching the modified modules passes (795 tests, 0 failures, 2 pre-existing environment skips).

Known limits (documented in-code)

  • Throughput is unoptimized; the output-LoRA path currently re-dequantizes per token and dominates decode cost.
  • Indexer/attention score tensors grow with context in one-shot prefill; the smoke harness documents a context ceiling pending chunked scoring.
  • Compressed rows are retained rather than evicted, matching the reference (the filter bounds what is attended, not what is stored).
  • MTP speculative decode for this model is in progress separately; this PR is the AR backend.

🤖 Generated with Claude Code

davidtai and others added 11 commits July 31, 2026 20:50
…o-LoRA math

Add mtplx/models/deepseek_v4.py, a from-scratch MLX loader scaffold for
DeepSeek-V4-Flash (model_type: deepseek_v4). No deepseek_v4 loader exists in
mlx-lm; this ports the reference inference/model.py + kernel.py structure.

M1 deliverable (study + skeleton):
- ModelArgs from the mlx-community 4bit config (43 layers, 256/6 experts,
  1 shared, q_lora_rank 1024, o_lora_rank 1024, o_groups 8, head_dim 512,
  index_topk 512, hc_mult 4, sinkhorn_iters 20, per-layer compress_ratios,
  num_hash_layers 3).
- Module tree mirrors the checkpoint key names exactly (verified 0-missing
  against model.safetensors.index.json): attn.{wq_a,wq_b,wkv,wo_a,wo_b,
  q_norm,kv_norm,attn_sink}, attn.compressor.*, attn.indexer.*,
  {attn_hc,ffn_hc}.{fn,base,scale}, ffn.{gate,switch_mlp,shared_experts},
  model.hc_head.*, model.{embed_tokens,norm}, lm_head.
- Implemented + reused (understood): sqrtsoftplus/hash/noaux MoE gate,
  SwitchGLU experts + clamped shared expert, interleaved YaRN RoPE, the
  Hyper-Connections pre/post + Sinkhorn transcription, HeadHC collapse, and
  the grouped output-LoRA einsum.
- New-math components (HCA sinkhorn, CSA compressor pooling, o-LoRA, hash
  routing) are present but NOT yet numerically gated — that is M2. The sparse
  top-k gather, compressor decode state machine, and streaming KV caches are
  marked NOTE(M3). Reference GPU kernels need CUDA/tilelang (cannot run here);
  the M2 oracle is a faithful transcription of their documented elementwise math.

Smoke: builds on a shrunk config and runs a tiny end-to-end forward to logits.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
(cherry picked from commit 62e3f3a10c62e4fa6a0b9b78412535fa78a0a968)
…erence

Gate HCA/CSA/o-LoRA/hash against the authoritative reference
(deepseek-ai/DeepSeek-V4-Flash inference/model.py + kernel.py) on small
synthetic tensors. The MLX components were first driven against the *actual*
reference torch classes on CPU (shipped tilelang/CUDA kernels stubbed with a
pure-torch transcription; QAT FP8/FP4 dropped on both sides); every component
matched formula-exact:

  sinkhorn pre/post/comb   max_abs ~1e-7   (comb doubly-stochastic confirmed)
  hc_pre.y / hc_post       max_abs ~2e-7
  compressor.kv            max_abs ~7e-7   (non-overlap ratio, prefill)
  o-LoRA grouped proj      max_abs  0.0    (bit-identical)
  MoE gate (hash + score)  index sets identical, weights max_abs ~3e-8

Key finding pinned in the test: MLX GPU fp32 matmul uses a reduced-precision
fast path (~7.5e-4 relative vs true fp32, still sub-bf16); MLX CPU fp32 is
bit-identical to IEEE fp32. Gates run on the CPU device so the tight bound
isolates algebra, not hardware matmul precision.

Committed test (tests/test_deepseek_v4_new_math.py) is a self-contained NumPy
oracle (no torch, no download) that reproduces the same checks as an always-on
regression guard; 7/7 pass under the serving venv. Model change: Compressor now
owns its YaRN rope (window w -> position w*ratio) so it is independently gate-able.

Not yet gated (deferred to M3, called out in-code): overlapping-window (ratio==4)
compressor fold, Indexer top-k selection + Hadamard, the streaming decode KV
state machine, and the sparse windowed attention integration.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
(cherry picked from commit 7f8794eff160407ddcf08209d3df626a708eac3d)
…ad path

Make `mtplx serve` resolve DeepSeek-V4-Flash to the native MLX loader.

- runtime._model_classes_for_config: model_type "deepseek_v4" -> the vendored
  mtplx.models.deepseek_v4 (Model, ModelArgs). mlx-lm's load_model reads the
  mixed per-module quantization straight from the on-disk config.json
  (routed experts mxfp4/32, everything else affine 4/64), so
  laguna_module_quantization stays None and no model_config override is needed.
- registry: add arch_id "deepseek-v4" (family deepseek, backend deepseek_v4,
  native-ar-only, can_run_verified) to ARCHITECTURE_CATALOG + SUPPORTED_ARCH_IDS,
  plus a family gate (_passes_deepseek_v4_gate) on model_type/architecture. The
  mlx conversion drops the MTP block, so the artifact runs target-only AR.
- Disambiguate from the existing "deepseek-v4-mtp" entry: "deepseek_v4" is a
  substring of "deepseek_v4_mtp", so the AR entry is given a single short alias
  and the MTP-split entry keeps only "deepseek_v4_mtp" — this preserves its
  longer-alias detection priority for MTP configs.

Verdict for a deepseek_v4 config: tier AR-only, can_run=True, backend
deepseek_v4, exit 0.

Tests: drop deepseek_v4 from the backend-pending parametrization (it now has a
backend) and add test_deepseek_v4_routes_to_supported_ar_backend +
test_deepseek_v4_mtp_split_stays_backend_pending. Full test_artifacts.py +
test_forge_cli.py green (148 passed).

Co-Authored-By: Claude Opus <noreply@anthropic.com>
(cherry picked from commit 5e31ea508262641428860e0a4d2f3797dbb70888)
…te harnessed

The full mlx-community DeepSeek-V4-Flash-4bit checkpoint (33 shards, ~151 GB) is
local. Verify the load path against it and stage the assembled-logits gate.

Loader:
- _o_lora now dequantises wo_a when it loads as a QuantizedLinear (grouped
  [g*r, per] -> dense -> [g, r, per] einsum); the unquantised M2 path is
  unchanged. This was the only forward that read a raw .weight and so needed
  quant-awareness.
- Load-path key set is EXACT: instantiating the full 43-layer structure at tiny
  per-unit dims and applying mlx-lm's quantise predicate (module quantised iff
  `{path}.scales` in the checkpoint; switch_mlp mxfp4/32, rest affine 4/64)
  reproduces the checkpoint's 2481 keys with 0 missing / 0 extra.
- Real-weight component spot-check (real dequantised tensors vs NumPy oracle):
    HC (real attn_hc L0)        finite, comb doubly-stochastic, y matches
    Compressor (real L3 r128)   kv matches   max_abs ~2.4e-7
    o-LoRA (real L3 wo_a)       matches      max_abs ~2e-7
    gate score (real L3)        valid top-6, weights sum to route_scale 1.5
    gate hash (real L0)         tid2eid index match

tests/test_deepseek_v4_loader.py: always-on module-tree spec check + cache-gated
real-checkpoint exact-key match + real-weight component gate (skips when the
checkpoint is absent). 3 passed.

Full 43-layer first-token logits gate: scripts/deepseek_v4_logits_gate.py. Not run
here — the model needs ~112 GiB wired (coordinator's guarded GPU window), and the
HF reference (inference/model.py) needs CUDA/tilelang + 284B params so it cannot
run on this box; llama.cpp has no deepseek_v4 arch. The harness proves the
assembled stack runs and yields finite, non-degenerate logits, and accepts
--ref <logits.npy> for the exact "logits match ref" diff once a reference dump
exists. This is the one acceptance item I could not measure locally.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
(cherry picked from commit d2239a3bba51af295fd17f944a8cb559d6132412)
Serving a checkpoint whose config declares MTP (num_nextn_predict_layers
> 0) but whose conversion dropped the MTP weights — e.g.
mlx-community/DeepSeek-V4-Flash-2bit-DQ, model_type deepseek_v4, 43 trunk
layers 0..42, no layer 43, no mtp.* keys — crashed at load with
"RuntimeError: MTP injection failed" (runtime.py). The inject_* functions
already return False when no MTP weights are present; the runtime gate
treated that identically to a genuine injection failure and raised.

Robust degrade: add artifacts.mtp_weights_present_on_disk(), a
conservative disk-level probe that returns False only when a shard index
positively confirms no MTP-shaped weights under either naming convention
(namespaced mtp.*/language_model.mtp.*, or a DeepSeek-style trailing
model.layers.{num_hidden_layers+i}.* decoder layer) and no sidecar file.
Any ambiguity (sidecar present, no index, unreadable index) returns True.
The runtime gate now splits the raise: inject succeeded -> validate as
before; inject failed with weights present on disk -> raise (real failure
the operator should see); inject failed with weights positively absent ->
log and serve autoregressive. Weight-bearing models (glm/hy3/qwen3.5/
deepseek-v3/nemotron-h/mimo/step3p5) never reach the new branch — the
probe returns True for them and the validate path is byte-identical to
the original, so the shared block is not regressed.

Tests (tests/test_mtp_weightless_degrade.py, 6 passed): deepseek_v4-style
weightless probe -> False; the real 2bit-DQ snapshot probe -> False when
that build is in the local HF cache (read-only, config + index only, no
weights loaded; skipped otherwise); non-regression probe -> True for
sidecar / namespaced-embedded / deepseek-trailing-layer and the
conservative no-index case.

Co-Authored-By: Claude Opus <noreply@anthropic.com>

(cherry picked from commit c54a2d105c6eeb7fc906a4b5b974699beedd6c24)
…forward was wrong

The M2 component gates passed at ~1e-7 each, but the assembled 43-layer forward
produced repetitive garbage on real weights. Full-stack parity bisect against the
reference (inference/model.py, torch CPU, shrunk seeded config, identical ported
weights, layer-by-layer) localized it:

  first divergence: the attention sub-block of every compress_ratio != 0 layer
  (L1 ratio-128 diverged while L0 ratio-0 was clean at 1e-7).

Root cause: DeepseekV4Attention only did dense sliding-window attention over the
per-position KV and NEVER called the compressor — so 41/43 layers dropped the
compressed long-range KV entirely (attention/position collapse).

Fix (mtplx/models/deepseek_v4.py):
- Attention.__call__ now concatenates the compressor's compressed KV to the
  per-position KV and attends over both with a window+compressed causal mask
  (_attn_mask), compress-YaRN rope, and per-head attn_sink — a dense equivalent of
  the reference sparse_attn, exact whenever every compressed position is selected
  (n_comp <= index_topk). Removed the dead _causal_window_mask plumbing.
- Compressor.__call__ implements the overlap window-fold for ratio-4
  (_overlap_transform, reference overlap_transform), not just non-overlap ratio-128.

Also diagnosed and dismissed a test artifact (NOT a code bug): random tid2eid in the
harness produced tokens routed to the same expert twice, which the reference's
`y[idx] += ...` handles last-write-wins; the real checkpoint has 0 duplicate rows
across all hash layers, so SwitchGLU summing both slots is correct. Harness now draws
distinct experts per row to match the real model.

Parity after fix (shrunk config, all layer types window/ratio-4/ratio-128 x
hash/score): every stage ~1e-6, logits max_rel 1.8e-6, argmax 160/160.

Regression test (the missing test): tests/test_deepseek_v4_parity.py gates the whole
MLX forward — full logits + argmax and per-layer blocks — against a golden captured
from the validated reference (tests/fixtures/deepseek_v4_parity_golden.npz). Self-
contained (mlx + numpy, no torch, no download). deepseek_v4 suite: 12 passed.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
(cherry picked from commit a8075971b2ea1b7cbffaa0719028b6198e3fe485)
Generation could not run incrementally: make_cache() returned [None]*n and the
attention only had a from-position-0 path. This adds the decode state machine for
all three layer types and gates it against the parity-pinned one-shot forward.

DeepseekV4Cache (one per layer) carries three pieces:
  * window      — rotated per-position KV inside the sliding window. It drops rows
                  the newest query can no longer reach instead of masking them, so
                  a single-token step needs no window mask at all.
  * compressed  — every compressed KV row emitted so far. A window becomes
                  attendable by the very token that completes it, which is exactly
                  what the prefill mask's `c < (i+1)//ratio` allows, so the
                  compressed columns need no mask either — hence the compressor
                  runs before the step's scores are formed.
  * comp        — CompressorState: the in-progress window's projected rows
                  (post-ape for the gate) plus, on the ratio-4 overlap lane, the
                  previous window's rows that _overlap_transform folds in.

Compressor.step() pools incrementally and shares its pooling core (_pool) with the
gated __call__, so the two paths cannot drift; _attn_mask now takes absolute
positions and serves both one-shot prefill and cached multi-token chunks.

State machine adapted from ds4.c (antirez/DwarfStar4, MIT): compressor_decode_one,
compressor_pool_decode_state, kv_cache_push_raw/push_comp and the decode layer's
push-KV -> compress -> attend ordering. Math is stock MLX ops, no custom kernels.

Gate (tests/test_deepseek_v4_decode.py, shrunk seeded config, CPU, no downloads):
prompt prefill + token-by-token decode vs the one-shot forward, worst per-step
max_rel 4.1e-6 (bar 5e-5) and exact argmax everywhere, across P%ratio!=0, a
window-aligned prompt, two ratio-128 boundaries crossed inside the decode loop,
T past window_size, chunked prefill, and b=3. Six seeded mutations of the state
machine (window off-by-one, ape slot, dropped overlap state, wrong rope window
index, deferred emission, no eviction) all fail the gate.

Still deferred: the ratio-4 indexer top-k sparse filter (attention stays dense
over compressed positions, exact while n_comp <= index_topk).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 834c6b8c74802a007b8134f53c696461bf7b79a0)
… gate

Two guarded-window harnesses for the deepseek_v4 backend on the real
mlx-community/DeepSeek-V4-Flash-2bit-DQ checkpoint (89.9 GiB, mixed affine:
2-bit routed experts, 4-bit everything else, 641 per-path overrides).

deepseek_v4_smoke_generate.py loads through the mtplx path
(mtplx.runtime._load_base_model -> mlx_lm load_model with get_model_classes),
greedy-generates off Model.make_cache(), and reports load / prefill tok/s /
decode tok/s / peak memory.  It refuses a total context past 2048 tokens: the
ratio-4 attention is dense over compressed positions and only exact while
n_comp <= index_topk, so a longer run would not be a valid coherence verdict.

deepseek_v4_decode_verify.py separates "streaming decode is wrong" from "the
model looped" when a run comes out degenerate.  GATE A replays a run's whole
token sequence through the one-shot (cache=None) path that
tests/test_deepseek_v4_parity.py gates against the reference, and compares its
argmax at every generated position against what streaming emitted.  GATE B
greedy-generates on control prompts whose continuation is determinate.

First measured results (M5 Max, 43 layers, greedy, 328-token code prompt, 128
new tokens): load 10.6 s (page-cache warm), prefill 47.1 tok/s, decode 4.51
tok/s, peak 91.69 GiB.  GATE A: 128/128 streamed tokens match the one-shot
argmax, so the DeepseekV4Cache state machine is exact at real dims.  Decode
tok/s is dominated by DeepseekV4Attention._o_lora dequantising wo_a per token
per layer, which is a known open item and untouched here.

No backend change: mtplx/models/deepseek_v4.py is unmodified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 71d66171dae687cca5f96281d7d59da9c62377cf)
…decode)

Attention on a compress_ratio==4 layer attended *every* compressed row, which is
only the reference's computation while n_comp <= index_topk (~2K tokens of
context at the shipped 512/4).  Past that the backend was simply computing
something else: at a shrunk index_topk the dense-over-compressed answer diverges
from the reference by 37% relative on a 60-token sequence.

Indexer now scores every compressed row per query — its own Hadamard-rotated
compressor lane, wq_b/weights_proj, sum_h relu(q_h . row) * weights[h] — and
returns a [b, s, n_comp] boolean selection that attention folds into its additive
mask.  The reference returns gathered indices with -1 for unusable slots for its
sparse kernel; the mask is the same computation expressed densely, gated against
a gather-shaped oracle.

Per-query k = min(index_topk, n_causal(q)) is the reference's global-k-then-
invalidate (model.py L424-430) evaluated directly, as ds4.c does; it also covers
chunked prefill, which the reference has no branch for.  Ties go to the lowest
index (ds4.c's selection loop) so the one-shot and streaming paths cannot resolve
an exact score collision differently — rows whose every head ReLUs to zero
collide constantly.

DeepseekV4Cache grows a second CompressorState + row buffer for the indexer lane
(ds4.c carries index_state_kv/index_comp_kv beside the attention lane's),
maintained on every ratio-4 step so a context that crosses index_topk mid-decode
has rows to score.  Neither lane evicts — matching reference and ds4; the filter
bounds what is attended, not what is kept.  Cache state 6 -> 11 entries, meta 4
-> 5, version v1 -> v2 (nothing outside the model reads either).

Below the threshold the scoring path is skipped outright, so the short-context
regime stays bit-identical and the existing parity golden is untouched.

QAT: the reference's FP4 emulation on the indexer's q and rows is dropped, as the
attention compressor's FP8 already was.  The Hadamard rotation it wraps IS
implemented (it is graph, not noise) — but it is orthogonal and applied to both
sides of the same dot product, so it cannot change a selection on its own;
removing it from both sides is caught only by the row-level reference oracle.
FP4 is therefore the one remaining divergence in the selection graph, and it is
the divergence ds4.c's QAT comment is about.

Gates (tests/test_deepseek_v4_indexer.py, 14 new): gather-shaped reference oracle
max_rel 2.6e-07 (dense-over-compressed: 3.8e-01); indexer scores 2.5e-07;
one-shot vs prompt+step decode in the sparse regime max_rel <= 2.9e-06 with exact
argmax across partial-window, dense->sparse crossing, chunked prefill, b=3,
single-token prompt and aligned prompt.  12/12 mutations killed.

Reference: deepseek-ai/DeepSeek-V4-Flash inference/model.py (Indexer L380-433,
Attention L484-543, Compressor L279-377) + inference/kernel.py (sparse_attn
L294-352), fetched read-only.  Selection semantics, tie-break, cache layout and
the Hadamard butterfly adapted from ds4.c (antirez/DwarfStar4, MIT).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit fb53836a1ec2a492243fe182a18130118aaf6b2a)
…refill + GATE A

The 2048-token refusal existed because the ratio-4 indexer top-k filter did not
exist: past index_topk*ratio the dense-over-compressed path silently stopped
being the reference's computation, so a longer run could not be a valid verdict.
fb53836 landed the filter, so that reason is gone.

What still bounds a run is memory, not math -- the indexer's
[b, s, index_n_heads, n_comp] fp32 score tensor and attention's
[b, n_heads, s, n_win + n_comp] tensor are both quadratic in context. The cap
is now --max-context (default 8192) and is documented as that memory ceiling;
long-context score-tensor cost stays an open item.

Also, so one 90 GiB load can produce a full verdict:
  * --prompt-file/--out take several pairs, run off a single load;
  * --prefill-chunk feeds the prompt to the live cache in bounded chunks
    (the serve path's shape, gated by test_sparse_chunked_prefill_then_decode),
    keeping the prefill half of the quadratic cost flat.  Default 0 keeps the
    single-call path byte-for-byte;
  * --gate-a replays prompt+generated through the one-shot (cache=None) forward
    -- the parity-gated path -- and counts argmax agreement per generated
    position, which is what gates the streaming state machine at real dims.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit fa7a36321d8d55b86bcb7411194682b31e2065fb)
The 3318-token sparse run gated 127/128, diverging only at generated index 92.
This separates "the streaming state machine is wrong past index_topk" from
"both paths compute the same function and an argmax flipped on a near-tie" by
measuring, at the one query position that disagreed: the output margin, the
indexer's selected row set per ratio-4 layer, and the top-k boundary headroom.

Measured (bench/deepseek-v4/sparse-2bitdq-20260731-probe.json):
  * structure is right -- offset 3410, n_comp 852 on all 21 ratio-4 layers in
    both paths, exactly 512 rows selected in both;
  * the streamed replay reproduces the receipt for indices 0..91 verbatim;
  * given the SAME 3410-token prefix the one-shot also picks 362, i.e. it
    agrees; GATE A disagreed only because its one-shot runs over 3445 tokens,
    which moves n_comp to 861 and re-shapes every reduction;
  * the rank-512-vs-513 score gap is 2e-4..1e-2 on 21/21 layers while the
    inter-path score noise is 4e-3..2.9, so the cut sits inside the noise;
    18/21 layers select a slightly different row set and the logit rows spread
    by mean 0.11 against a top1-top2 margin of 0.035-0.088 at that position.

So the divergence is the discrete top-k boundary the Indexer docstring already
flags, not a defect: no backend change. The smoke harness now says how to read
a sparse GATE A -- by where it diverges, not by an exact count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit acfe5c4b900200755d0bbbbc5d17da845655d946)
@davidtai
davidtai requested a review from youssofal as a code owner August 1, 2026 02:00
davidtai and others added 11 commits July 31, 2026 21:26
…eference does

The reference applies the swiglu clamp inside *every* expert -- MoE.__init__
L624 hands swiglu_limit to each routed Expert exactly as L627 does for the
shared one -- and Expert.forward L600-602 is asymmetric about it:

    up   = torch.clamp(up, min=-self.swiglu_limit, max=self.swiglu_limit)
    gate = torch.clamp(gate, max=self.swiglu_limit)

i.e. the up branch (w3/up_proj) is clipped two-sided, the gate branch
(w1/gate_proj) only has its upper tail cut and keeps its whole negative range,
and both cuts land on the pre-activation projections. DeepseekV4MLP already had
this for the shared expert; the routed experts went through stock SwitchGLU,
which never clamps.

SwitchGLU's `activation` module sits precisely where the reference's clamp does,
between the up/gate projections and down_proj, so the faithful port is an
activation rather than a fork: ClampedSwiGLU subclasses mlx-lm's SwiGLU and the
batched gather_mm/gather_qmm expert kernels are untouched. It holds no
parameters, so the weight tree and the sanitize -> quantize -> load_weights path
are unchanged. Note SwitchGLU calls activation(x_up, x_gate) -- first argument is
the up branch, the opposite of what the names suggest.

DeepseekV4MoE.__init__ is the only place this backend builds routed experts, so
trunk score layers and trunk hash layers are both covered by construction; a test
asserts both sites are wired and functionally clamped so a future extra
construction site cannot slip past.

At swiglu_limit <= 0 the activation defers to stock SwiGLU, so that path is the
same fused swiglu kernel it was before. Verified bit-identical (max_abs 0.0, not
a tolerance) against the pre-change module on the parity golden's full-stack
logits and every per-layer block -- which is what keeps the captured golden,
taken at swiglu_limit=0, valid.

Gate: tests/test_deepseek_v4_swiglu_clamp.py, a NumPy float64 transcription of
Expert.forward + MoE.forward with the clamp ACTIVE and inputs driven into
saturation on all four sides. The gate asserts the saturation itself -- >5% of
pre-activation values past +/-limit on each side, including gate values below
-limit, which is the only thing that distinguishes the reference's one-sided gate
clamp from a symmetric one. Covers SwitchGLU's unsorted and gather-sorted expert
paths (indices.size >= 64) and both gate kinds.

Ten implementation mutations all caught. Nine re-run against this branch and
caught by >=1 gate each: clamp removed, loosened 4x, branches flipped, gate made
symmetric, clamp moved post-activation, up/gate roles swapped, activation not
wired into SwitchGLU, limit<=0 path made non-stock, and inputs scaled below the
limit (the vacuity guard, which fires on 11 of the 16). The tenth, up made
one-sided, is one of the six oracle modes the passing mutation gate parametrizes
over.

Deepseek suite 39 -> 55 passed.

Deferred: the activation ranges real V4-Flash weights actually reach, i.e. how
often the clamp binds in practice. That needs a checkpoint load and belongs to a
GPU window.

Adapted from b1aeab9 (feat/deepseek-v4-backend), which was written on top of the
MTP-module commits this branch does not carry. Deltas, all MTP-only: the
tests/test_deepseek_v4_mtp.py docstring hunks are dropped (no such file here);
the MTP draft block is dropped from the two "every routed-expert site" tests and
from the module/class docstrings, leaving the trunk's score and hash layers,
with a comment marking where the draft block joins the site list once it lands;
the clamp test's config drops num_nextn_predict_layers; "both parity goldens"
reads as the one golden this branch has; and DeepseekV4MoE, which has no
docstring here, gains one rather than having a NOTE(swiglu_limit) paragraph
rewritten. The clamp mechanism itself is MTP-independent and lands unchanged.

(cherry picked from commit b1aeab91aa8a1edcad00ca4e94d18142bac4b533)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… scan

tests/fixtures/deepseek_v4_parity_golden.npz is the deterministic fixture the
deepseek_v4 parity suite gates the whole forward against — 2.3 MB of float32
toy-dimension arrays (64-wide hidden, 128-token vocab), loaded with
allow_pickle=False, not a checkpoint. The scan's artifact rule exists to keep
model weights out of git; this exempts exactly this file, mirroring the existing
Resources exemption, and everything else under tests/ stays forbidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mal load path

DeepSeek-V4-Flash ships one multi-token-prediction block upstream as mtp.0.*
(1575 tensors, all in shard 46 of 46), but every published MLX conversion drops
it while leaving num_nextn_predict_layers: 1 in the config -- the exact case
c54a2d1 added the degrade-to-AR guard for. This adds the block itself.

DeepseekV4MTP subclasses DeepseekV4DecoderLayer, because the reference MTPBlock
subclasses Block (model.py L738): the draft head is a full decoder layer with its
own attention, its own 256-expert MoE and its own two Hyper-Connection blocks,
plus six pieces of its own -- enorm/hnorm, e_proj/h_proj, and a norm + hc_head
for its own final collapse. It shares exactly two tensors with the trunk, the
embedding and lm_head (L792-793), which are passed into __call__ rather than
stored so the 129280-row pair is never duplicated. Its layer_id is n_layers (43),
which is what makes it a pure sliding-window attention layer (compress_ratios[43]
== 0: no compressor, no indexer, base rope_theta, no YaRN) with a score-routed
gate -- all inherited, none of it re-decided.

DeepseekV4Model.__call__ is split into hc_hidden() + collapse() so the draft
block can read the pre-head [b, s, hc, dim] state the reference hands it;
__call__ recomposes them and is unchanged (gated by a test). Model gains
hc_hidden / logits_from_hc_hidden / mtp_forward / make_mtp_cache -- the seams the
speculative lane needs. The MTP block gets its own DeepseekV4Cache: its attention
is a separate module with separate KV, and at ratio 0 that cache is a plain
sliding window with nothing to roll back but the window and offset.

No sidecar, no env var: the block lives at mtp.0.* in the module tree, so a
checkpoint that ships those tensors binds through the ordinary
sanitize -> quantize -> load_weights(strict=True) path. num_nextn_predict_layers
alone is not trustworthy (the published conversions lie), so sanitize() lets the
WEIGHTS decide -- present keeps the block, absent drops it from the tree so the
strict load still matches exactly instead of raising 58 missing keys, and the
runtime's degrade branch stays reachable byte-for-byte (runtime.py untouched).

tests/test_deepseek_v4_mtp.py (19 passed): whole-block parity against a NumPy
transcription of the reference MTPBlock/Block/Attention/Gate/Expert/MoE/hc_head
at a shrunk config, max_rel 2.0e-7 against a 1e-5 bound on two seeds; nine
implementation mutations (norm order, dropped e-projection, dropped hnorm, stale
HC stream, head order, hc_head sinkhorn, swapped attn/ffn HC, dropped attn_sink,
unnormalised routing weights) all caught; structure (layer_id, ratio 0, score
gate, no embedding/lm_head copy, ratio fallback when the config omits the entry);
both load paths incl. the quantized one; and the real merged checkpoint's key set
(index + config only, no weights read) with the degrade guard reading True for it
and still False for the untouched mlx-community snapshot.

Also documented, not changed: SwitchGLU has no clamped activation, so the
reference's swiglu_limit clamp reaches the shared expert but not the routed ones.
That gap is the trunk's, identical in the MTP block, and both parity goldens were
captured at swiglu_limit=0 -- so it is untested rather than measured.

deepseek_v4 suite 33 -> 52 passed; test_mtp_weightless_degrade 7 unchanged;
artifacts/mtp/registry/server sweep 482 passed.

Ported onto the PR branch, which already carries the ``swiglu_limit`` clamp
(landed here before this commit, in the reverse of the original order).  Two
adaptations follow from that swap:

  * the clamp commit's docstrings had been written for a branch with no MTP
    block ("trunk score layers and trunk hash layers alike"); they are restored
    to the three-site wording now that the third site exists;
  * tests/test_deepseek_v4_swiglu_clamp.py regains its ``mtp.0`` coverage in
    ``test_every_routed_expert_site_{carries,is_functionally}_clamped`` and its
    ``num_nextn_predict_layers`` config entry, which had been stripped for the
    same reason.

Nothing else changed: mtplx/models/deepseek_v4.py and
tests/test_deepseek_v4_mtp.py are byte-identical to the source lineage.

(cherry picked from commit 5a2152625b17e2d51a7e07d1f8b0c67b1e70dcbf)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deepseek_v4_build_mtp_model.py merges the upstream draft head into the MLX trunk
as ONE stock-served directory -- no sidecar, no manifest, no env var. It
hardlinks every file of the mlx-community snapshot (zero new bytes, the HF cache
is never written to), writes the converted mtp.0.* tensors as one extra shard,
and rewrites only the two files it must edit: model.safetensors.index.json gains
the 58 new entries and an updated total_size, config.json gains the 13 per-path
quantization overrides through the same mechanism the trunk's own 641 use.

Dequantization is transcribed from the reference, not guessed:
  * dense projections are FP8 e4m3 [out, in] with an e8m0 scale
    [ceil(out/128), ceil(in/128)] -- w * scale[n//128, k//128]
    (Linear.__init__ L138-142, fp8_gemm_kernel L242-249);
  * routed experts are FP4 e2m1 packed two-per-byte along K, stored [out, in//2],
    with an e8m0 scale [out, in//32] -- fp4(w) * scale[n, k//32]
    (L131-137, fp4_gemm_kernel L498-509);
  * e8m0 is exponent-only, so a scale is exactly 2**(byte - 127).

The FP4 nibble order is the one assumption that could silently corrupt every
expert, so it is not left as one: the decode is asserted bit-exact against MLX's
own independent mxfp4 dequantizer (max_abs_diff 0.0), which implements the same
OCP packing. Two invariants of the reference quantizer corroborate the scale
semantics -- fast_round_scale (kernel.py L36-37) forces the per-group max
magnitude into (fmt_max/2, fmt_max], and the built bank lands at [4, 6] for FP4
and [224, 448] for FP8 exactly as it must.

Output is affine 8-bit / group_size 64: MTP precision is a floor, never traded
for memory without a measured acceptance A/B, so nothing goes below q8 even
though the routed experts are natively 4-bit. bf16 holds every FP8/FP4 source
value exactly (both are <= 4 significant bits with power-of-two block scales), so
the only loss is the 8-bit affine grid itself, measured per stem: relative
Frobenius 0.7%-1.6%, max error 0.4%-1.0% of tensor absmax.

deepseek_v4_mtp_bind_check.py is the real-weight gate that does not need a
guarded window: it builds only the draft head plus the embedding and lm_head it
shares, quantizes with mlx-lm's own class_predicate rule against the merged
config, and binds strictly. 64/64 params, zero missing, zero extra, peak 7.99
GiB, finite logits, and a cached single-token step through make_mtp_cache's
shape contract.

Built artifact: ~/models/DeepSeek-V4-Flash-2bit-DQ-mtp -- 7.03 GB of new blocks
(the mtp shard) on top of hardlinks to the 96.52 GB trunk, 103.55 GB / 96.44 GiB
declared total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Ported unchanged (script-only, both files new, applies clean on upstream).  The
default bank here is the superseded affine-q8 one; a later commit in this series
makes ``--bank exact`` the default, which is what reproduces the shipped bank.

(cherry picked from commit 97990fc212c96f188ab88e1024b3356e889a1287)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Speculation's only real risk on this backend is the cache: a rejected draft
that does not fully un-decode leaves every later token conditioning on state
the committed prefix never produced.  DeepseekV4Cache.is_trimmable() was False
for exactly that reason.  It is now True, with trim() rewinding all three lanes
by three different mechanisms, each picked because it is exact rather than
cheap:

  * emitted compressed rows (attention + indexer) TRUNCATE -- a row is a pure
    function of one completed window, so a shorter context's rows are a prefix
    of this one's;
  * the compressor frontiers rebuild from a bounded JOURNAL of their own
    projected rows, because a rewind across an emission boundary needs rows the
    frontier already dropped and the cache holds no hidden states to recompute
    them from;
  * the sliding window RETAINS rollback_capacity extra rows, because eviction
    is irreversible.  Retention and attendance are now separate: update_window
    returns only the attendable prefix, so the deeper buffer cannot change the
    forward (the existing decode/indexer parity gates hold unchanged).

trim() past rollback_capacity raises instead of clamping: rollback_after_verify
ignores trim's return value, so a clamped rewind would leave a silently
desynced cache decoding on.  Default capacity 64 is an order of magnitude over
the K+1 the engine ever asks for, and costs ~20 MB of retained rows on the real
model.

Making trim exact is what lets the engine's own all-trimmable rejection repair
(cache_state.trim_verified_window_without_snapshot) serve this backend -- no
bespoke snapshot/restore path.  The rest of the lane is the uniform runtime
surface on Model: __call__(return_hidden/emit_logits/logits_keep), mtp_forward,
mtp_update_cache, make_mtp_cache, plus inject_deepseek_v4_mtp_support to
publish it the way validate_mtp_support probes for.  The draft head is native
here (it binds from the checkpoint's own mtp.0.* paths), so the injection
grafts nothing; it wraps the block list in a container and reports False for a
checkpoint whose head sanitize() dropped, which is the degrade-to-AR signal.

Two knobs of the uniform draft signature raise rather than being ignored --
position_offset (the draft's RoPE comes from its own cache) and
input_embeddings (no vision splice) -- because silently dropping either would
corrupt drafting instead of failing.  hidden_variant and concat_order are
accepted and ignored, as on every other appended-layer backend: V4's draft
input is one tensor the reference defines, with nothing to pick between.

Registry: deepseek-v4 is documented as the runnable V4 MTP arch, since that is
what both an AR-only conversion and an MTP-bearing merged directory detect as.
deepseek-v4-mtp stays backend-pending on purpose -- it describes vLLM's SPLIT
checkpoint layout, which no MTPLX loader assembles; promoting it would tell
forge a two-repo artifact is runnable.

Ported onto the PR branch.  The injector hookup is re-expressed against
upstream's chain, which lives in ``runtime.load()`` rather than the fork's
``_load_impl()`` and orders its ``elif`` arms differently.  The V4 arm still goes
first, but the comment justifying that was corrected while re-expressing it:
``is_deepseek_mtp_config`` keys on ``model_type`` in {deepseek_v3, deepseek_v32,
glm_moe_dsa}, which is disjoint from the V4 predicate's {deepseek_v4,
DeepseekV4ForCausalLM} -- so the ordering is defensive rather than load-bearing,
and the original "matches the same appended-layer markers" claim was not true of
either branch.  Registry entries, model surface and the two adjusted cache gates
apply unchanged.

(cherry picked from commit ebad5facff3b8c9512940856f7bc278cfb96317c)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mutations

Three layers, strongest last.

Rollback exactness (unit): decode k extra tokens in one verify-shaped forward,
trim(k), and require the cache to be the one the shorter context would have
held -- bit equality, not tolerance -- on every lane, across a ratio-4 emission
boundary, the ratio-128 boundary, the indexer's dense->sparse threshold, a
partial window, a rewind that lands before any emission at all, batch>1, and
ten repeated verify/reject cycles.  The headline form is that the next tokens'
logits are bit-identical to the never-decoded arm.

One field is excluded from "every field" and asserted more weakly, on purpose:
the retained depth of the window buffer and the frontier journals.  Those are
bounded, so overshooting by k and rewinding evicts up to k rows off their old
end that a shorter run would still hold.  Those rows are unattendable and
unemittable by construction -- rollback headroom, not model state -- so they
are gated as newest-suffix equality plus a coverage floor, and both regimes are
covered (under the cap EVERY field including meta_state is exactly equal).

spec==AR: generate_mtpk at depth 1/2/3 must emit the identical greedy sequence
as generate_ar, run against the real mtplx.generation machine rather than a
hand-rolled loop, so the registry/runtime wiring is gated too and not just the
model.  Lengths put verify batches across window eviction, ratio-4 and
ratio-128 emissions and the sparse threshold.  A small-vocabulary arm makes the
untrained draft head agree often enough that accept and reject paths both run
in one gate, and the per-depth acceptance counters the bench window reads are
asserted populated (a backend whose draft never fires would report zeros and
read as 100% rejection).

Mutation gate: a stale compressor frontier, an off-by-one on the emitted-row
drop, an un-rewound indexer lane and a window rewind past retention.  Each must
be caught by BOTH halves of the gate -- the state comparison (the rewind is
wrong) and 24 further decode steps (the wrongness reaches the model) -- because
either alone can miss one: off-by-one hides behind the indexer's top-k for
several steps, and a stale frontier trips the backend's own lane-desync assert
before logits ever diverge.

Ported unchanged (new file, applies clean on upstream): 23 passed.

(cherry picked from commit 4c50269f18a98d583714e3b7a90d7fb6218bcfe2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…aft bank

The merged dir was rebuilt so the draft head carries zero avoidable conversion
error: routed experts are mxfp4 group_size 32 (a byte repack of the source FP4
e2m1 payload + its e8m0 scales, max_abs_diff 0.0 over 768/768 tensors) and the
dense projections are plain bf16 with their quantization entries removed.  The
key-expansion gate was written against the superseded affine-q8 bank and failed
on it twice over:

  * it enforced a BIT FLOOR (bits >= 8).  That is the wrong metric here -- a
    4-bit count says nothing about fidelity when the source is itself 4-bit and
    the bank is a repack, and re-quantizing those tensors to affine 8-bit would
    be strictly worse despite the larger number.  The rule is now "no lossy
    re-quantization": mxfp4 is allowed explicitly, affine still has to be >= 8.
  * it expanded {weight, scales, biases} unconditionally.  mxfp4 stores no zero
    point and ships no .biases, so that invented three keys
    (mtp.0.ffn.switch_mlp.{gate,up,down}_proj.biases).  Biases are now expanded
    only for mode == affine, and the expansion matches the checkpoint exactly:
    0 missing, 0 extra.

Also adds two gates the rebuild needs: the shipped bank's own losslessness
claim read back off the artifact (only the routed experts carry a quantization
entry, all mxfp4, and the recorded receipts are 0.0), and a synthetic load-path
test for the mixed shape -- mxfp4 experts beside dense bf16 projections must
bind strictly, with no .biases keys and with the projections staying plain
nn.Linear.  The all-affine test keeps running under its own name because that
bank is retained on disk as *.q8-bank.bak for an acceptance A/B.

Ported unchanged (test-only, applies clean on upstream).  This is the commit
that turns the merged-dir key gate green against the mxfp4/bf16 bank actually on
disk; the two commits before it in this series fail that one gate for the same
reason they did in the source lineage.  tests/test_deepseek_v4_mtp.py: 21 passed.

(cherry picked from commit a344e0d53806ce1e8a37bc287716c7471ec9edd1)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The builder still produced the superseded affine-q8 bank, so it no longer
reproduced the merged directory on disk.  Ported the exact-representation path
in as the default (--bank exact):

  * routed experts stay FP4 -- MLX's mxfp4 is byte-identical to the source
    layout (uint32 words of 8 e2m1 values low-nibble-first, one uint8 e8m0
    scale per 32), so the payload is REPACKED via w_u8.view(uint32) plus the
    scale bytes verbatim, never decoded and re-encoded.  repack_fp4_to_mxfp4
    asserts the identity that licenses the view: mx.dequantize(mode="mxfp4")
    equals the reference LUT decode of the source bytes exactly, per tensor --
    which subsumes the old one-off check_fp4_against_mlx witness;
  * FP8 e4m3 x e8m0 dense projections are written as plain bf16 with no
    quantization entry, since every such value has <= 4 significant bits and a
    power-of-two block scale.  dense_exact checks that per tensor instead of
    trusting the argument, and falls back to float32 with a printed warning;
  * plain tensors keep their source dtype, round-trip asserted.

--bank affine-q8 reproduces the old bank and is documented as what it is: the
lossy A/B arm, kept because the merged dir retains that bank as *.q8-bank.bak
so an acceptance comparison stays reproducible.  The reader, the e4m3/e2m1/e8m0
LUTs, the rename tables and the fast_round_scale group-max invariants are
untouched, so the receipts are stated against the same reference as before.

Provenance now records which bank was built and how each family is represented.

Ported unchanged (script-only, applies clean on upstream).  ``--bank exact`` is
the default, so a plain invocation reproduces the bank the benchmarks were run
against; ``--bank affine-q8`` stays as the documented A/B arm.

(cherry picked from commit a4f1f65f84d3f14b02c7a9e2f8a3ca375a139053)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lback bound

The session bank's near-prefix restore trims a restored snapshot to an arbitrary
matched prefix, which this cache cannot serve at any capacity worth paying for --
the window rows are physically gone. Names the two ways out so whoever enables
that lane for V4 does not rediscover it from a raised ValueError.

Ported unchanged (docstring-only).  Both symbols it names resolve on upstream:
generation._trim_cache_to_offset (generation.py) and
rollback_after_verify (cache_state, re-exported through generation).

(cherry picked from commit 5a20d3faaee32cd4192b3aaab486193b2dc6a8cc)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ot on and off

Each strategy repairs a rejection through a different path and all of them lean
on trim being exact: trim_commit/target_prefix commit by trimming the verify
tail, capture_commit falls through to the same trim (pure-attention model, no
recurrent state to capture), batched rolls the whole verify back and
re-forwards. Under MTPLX_SKIP_VERIFY_SNAPSHOT=1 -- the product-profile default
-- there is no snapshot either, leaving only the engine's snapshot-free
all-trimmable repair. Eight combinations, all landing on the AR sequence.

Ported unchanged (test-only).  All four strategy names are members of upstream's
VerifyStrategy literal, so the parametrisation is valid there as written.
tests/test_deepseek_v4_spec.py: 31 passed.

(cherry picked from commit 202fa0901f55f8c803d68b069e451259fa720695)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ne load)

The spec lane gates spec==AR on a shrunk seeded model; this measures the same
lane on the real 2bit-DQ trunk plus its MTP bank, and carries the same gate
forward as a hard failure rather than a footnote -- a K arm whose committed
tokens differ from the AR arm's has a lossy rollback, and its tok/s means
nothing.

All four arms run off ONE load, in ONE guarded window, because cross-window
thermal drift on this box is 15-20% -- larger than the effect being measured, so
an arm paired against a number from another window measures the fan.  An
unrecorded AR warmup precedes them so the control is not the arm that pays
first-call allocator and kernel-compile cost (decode is stable across loads here
but prefill is not, and prefill is recorded per arm).

Arms go through mtplx.generation over a real MTPLXRuntime rather than a
hand-rolled loop, so the receipt covers the registry/runtime wiring and the
rejection-repair path, not just the model.  --tiny runs the whole shape on the
spec gate's shrunk model in seconds, which is how the harness was validated
before spending a 93 GiB load.

Ported unchanged (script-only, new file).  Re-validated here on this branch with
--tiny: all four arms run off one load and spec==AR PASSes at K=1/2/3
against the shrunk seeded model.

(cherry picked from commit 5a324082cd4d41b5c45bb668053adcf68f9d8fb2)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@davidtai

davidtai commented Aug 1, 2026

Copy link
Copy Markdown
Author

Speculative decode on DeepSeek-V4-Flash: the MTP draft head, wired and measured

DeepSeek-V4-Flash ships one multi-token-prediction block upstream as mtp.0.*, but the published mlx-community conversions (2bit-DQ and 4bit) ship without it while leaving num_nextn_predict_layers: 1 in the config. This adds the block and the speculative lane that drives it. The block, DeepseekV4MTP, is a DeepseekV4DecoderLayer subclass, exactly as the reference's MTPBlock subclasses Block: it has its own attention, 256-expert MoE and two Hyper-Connection blocks, and shares only the embedding and lm_head with the trunk. There is no sidecar and no env var. The block lives at mtp.0.* in the module tree, so a checkpoint that ships those tensors binds through the ordinary sanitize -> quantize -> load_weights(strict=True) path. sanitize() lets the weights decide, so a conversion without the head still drops cleanly into the existing degrade-to-autoregressive branch.

The one real risk in speculating on this backend is the cache: a rejected draft that does not fully un-decode leaves every later token conditioning on state the committed prefix never produced. DeepseekV4Cache.trim() rewinds all three lanes, by three mechanisms picked for exactness rather than cost. Emitted compressed rows truncate, since a row is a pure function of one completed window. The compressor frontiers rebuild from a bounded journal of their own projected rows, because a rewind across an emission boundary needs rows the frontier already dropped. The sliding window retains rollback_capacity extra rows, because eviction is irreversible; retention and attendance are separate, so the deeper buffer cannot change the forward. Asked to exceed that bound, trim raises rather than clamping. Exact trim is what lets the engine's existing rejection repair, which corrects by trimming state rather than snapshotting it, serve this backend with no bespoke snapshot/restore path.

Benchmark

bench/deepseek-v4/mtpk-2bitdq-20260731: four arms off one load in one guarded window, with an unrecorded 8-token AR warmup ahead of the measured arms. Arms are only ever compared inside a window; this box carries roughly 15-20% run-to-run drift across windows, which swamps the depth-to-depth gaps here (K=2 to K=3 is 5%). B=1, greedy, 328-token code prompt, 256 decode tokens, capture_commit / stock verify core / committed history policy. Model: DeepSeek-V4-Flash-2bit-DQ trunk plus the mxfp4/bf16 MTP bank, merged; 93.4 GiB active after load. M5 Max, macOS 26.5.2, MLX 0.31.2. Arms run through mtplx.generation over a real MTPLXRuntime, so the receipt covers runtime.load()'s injector chain and the generation machine, not only the model.

arm tok/s ms/tok x AR committed tok / verify call peak GiB spec == AR
AR 4.534 220.6 1.00 n/a 95.17
K=1 8.042 124.3 1.77 2.08 95.18 PASS
K=2 9.865 101.4 2.18 2.69 95.18 PASS
K=3 10.356 96.6 2.28 2.94 95.18 PASS

Per-depth acceptance:

arm d1 d2 d3
K=1 0.933 (112/120)
K=2 0.913 (84/92) 0.620 (57/92)
K=3 0.905 (76/84) 0.667 (56/84) 0.202 (17/84)

spec == AR is a hard gate in the harness: it sets exit status 1, because a K arm whose committed tokens differ from the AR arm's has a lossy rollback and its tok/s means nothing. All three arms emitted the identical 256-token id sequence as the AR control (spec_equals_ar.pass, first_divergence_index: null), and the decoded text matches character for character.

Depth beyond 3

This window ran K=1..3 only. A later sweep to K=5 (bench/deepseek-v4/goal-ab-20260731) shows the curve turning over after K=3 — in-window there, K=3 peaks at 25.9 tok/s, K=2 is level at 25.6, then K=4 falls to 22.6 and K=5 to 21.7 — and the counters say why: the draft chain runs to full depth unconditionally (drafted_by_depth is flat, [87,87,87,86,86] at K=5) while conditional acceptance collapses, with d5 accepting 0/86 and d4 3/86.

Two things about that sweep, both of which cut against leaning on it. It ran on a later branch that merges an unrelated decode-path change (cached o-LoRA dequant plus bf16 activation storage) into this lane; that code is not in this PR, and its absolute tok/s are not comparable to the arms above. More importantly, every K arm in that sweep failed spec == AR. bf16 activation storage removes the precision headroom that had been keeping batch-width-dependent rounding below the argmax. Every divergence was inspected and they are semantically equivalent code variants at near-ties rather than corruption, but the gate fails as written, and settling it needs a task eval rather than a byte comparison. So treat the depth curve there as a speed observation on different code. K>3 has never been run in this PR's configuration, and I am not claiming the turnover transfers — only that d3 acceptance is already down at 20% here, which leaves a fourth step little to recover.

Building the model directory

Speculation needs a model directory that carries the draft head, which no published conversion does. scripts/deepseek_v4_build_mtp_model.py builds one: it hardlinks every file of the MLX trunk snapshot (zero new bytes, the HF cache is never written to), translates mtp.0.* from the upstream deepseek-ai/DeepSeek-V4-Flash shard onto the MLX module tree as one extra shard, and rewrites only model.safetensors.index.json and config.json. It records source shard bytes and sha256 in an mtp_provenance block.

The default --bank exact repacks rather than re-quantizes. Routed experts stay FP4: MLX's mxfp4 layout is byte-identical to the source's, so the payload is viewed through rather than decoded and re-encoded, with mx.dequantize(mode="mxfp4") asserted equal to the reference LUT decode per tensor. The FP8 e4m3 x e8m0 dense projections are written as plain bf16, checked per tensor to hold every value exactly (it does on this checkpoint), with a float32 fallback and a printed warning if that ever fails. --bank affine-q8 reproduces the earlier lossy bank and is kept as an acceptance A/B arm. scripts/deepseek_v4_mtp_bind_check.py binds the draft head plus the embedding and lm_head it shares, so the load path can be gated without a full-model window.

Load the merged directory normally. is_deepseek_v4_mtp_config is the first branch of runtime.load()'s injector chain; the ordering is defensive rather than load-bearing, since the V3 predicate keys on model_type in {deepseek_v3, deepseek_v32, glm_moe_dsa} and cannot match a deepseek_v4 config. The injection only publishes the surface, since the block is already bound from the checkpoint's paths. An AR-only conversion detects as the same deepseek-v4 arch and keeps running target-only, unchanged.

Caveats

  • Depth > 1 is an MTPLX extension. The reference defines exactly one MTP block and predicts one token ahead. K=2 and K=3 chain that single block against its own output, which the reference does not specify and upstream DeepSeek does not claim.
  • One prompt, 256 tokens, B=1, greedy, one window. This measures speed and committed-sequence identity. No HumanEval or MBPP was run against this lane, so "the draft head does not change what the model writes" is established here as sequence identity on one completion.
  • The spec tests run on a shrunk seeded model on CPU with an untrained draft head. They gate the engine wiring and the rollback arithmetic, which is what they are for; real-weight coverage of this lane is the single bench window above.
  • spec == AR means the committed sequence is identical, which is the lane's documented invariant. Logits are not required to be bitwise equal: draft and verify are batch-shaped forwards, so the committed row's KV is projected inside a K+1-wide GEMM rather than alone, and that difference only stays below the argmax while there is precision headroom to absorb it.
  • trim raises on one real serving path. The session bank's near-prefix restore (generation._trim_cache_to_offset) trims to an arbitrary matched prefix, which can exceed rollback_capacity (default 64). On this backend the rows are physically gone, so it raises instead of returning the False that would let the caller fall back to a cold prefill. Serving V4 behind a session bank needs either a rollback_capacity sized for it or a max_rollback pre-check in that caller. Recorded in the trim docstring, not fixed here.
  • deepseek-v4 still carries support_level="experimental-native-ar-only". That field routes a checkpoint with no draft head to the AR-only verdict, which is the majority case as long as the published conversions ship without the block; an MTP-bearing artifact is resolved dynamically by the family gate instead. The entry's notes say so, but it is the one field in this PR that reads against the headline, so it is worth your call. deepseek-v4-mtp stays recognized-backend-pending because that entry describes vLLM's split checkpoint layout, and MTPLX has no loader that assembles a target from two repos.
  • Reproducing the benchmark is expensive. It needs the upstream shard plus a local mlx-community trunk snapshot, and produces a ~96 GiB directory that peaks at 95.18 GiB against this box's 100 GiB wired limit. The bind-check script covers the draft head alone and runs anywhere.

Tests

tests/test_deepseek_v4_mtp.py (21) gates the block against a NumPy transcription of the reference MTPBlock at a shrunk config, with a 1e-5 relative bound and 2e-7 observed, plus four transcription mutations that must all move the logits past the bound. tests/test_deepseek_v4_spec.py (31) gates rollback exactness bit-for-bit across a ratio-4 emission boundary, the ratio-128 boundary and the indexer's dense-to-sparse threshold; spec == AR at K=1/2/3 through the real generation machine; all four verify strategies with the verify snapshot on and off; and four rollback defects, three of which run through both the cache-state comparison and 24 further decode steps, with a fourth (a window holding no rollback margin) that must refuse rather than half-rewind.

The open question I would put to you is whether K>1 chaining belongs in the default path or behind a flag, given the d2/d3 acceptance curve and that the reference only defines depth 1.

claude and others added 5 commits August 1, 2026 03:53
`wo_a` is a static [o_groups*o_lora_rank, n_heads*head_dim/o_groups] matrix —
[8192, 4096] on DeepSeek-V4-Flash — and `_o_lora` ran `mx.dequantize` on it
inside every call: per token, per layer, 43 layers deep.  That is 64 MiB of
dense bytes written and re-read per layer per decoded token for a value that
never changes.  The reference does the dequant once and holds the dense matrix
(`wo_a = self.wo_a.weight.view(...)`, inference/model.py L537).

`MTPLX_DSV4_O_LORA` now selects how the weight is consumed:

  cached (default)  dequantise once, keep it.  The cache holds exactly what
                    mx.dequantize returned, so the consuming einsum is handed
                    the identical values and the path is bit-identical —
                    proven with mx.array_equal on the logits, one-shot and
                    streaming, over a config carrying every layer type, and
                    through the MTP draft block as well as the trunk.
                    Resident cost is 64 MiB/layer, 2.69 GiB across 43 layers.
  dequant           the old per-call behaviour, kept as the A/B control and as
                    the oracle the bit-identity gate compares against.
  gather_qmm        the 8 LoRA groups as one quantised block-diagonal matmul,
                    no dense tensor at all — the optimisation the reference
                    flags and declines (L538-539).  Not bit-identical (the
                    kernel dequantises inside the accumulation), so it is gated
                    on tolerance + argmax stability and stays off by default.

What it is worth, measured — and it is NOT the decode win.  On the real 2bit-DQ
checkpoint at fp32 activation storage (the state of the tree at this commit),
cached vs dequant is AR 4.534 -> 4.627 tok/s: +2.1%, inside this box's 15-20%
cross-window drift, i.e. not distinguishable from zero.  The code says why — at
fp32 the einsum promotes `wo_a` anyway, so caching removes the dequantize but
not the cast that followed it.  It also costs +2.69 GiB resident.  The 3.5x in
this lane belongs to the activation-dtype fix that follows; cached is kept
because it is bit-identical, gated, and removes real redundant work, not
because it is where the speed came from.  Receipts:
bench/deepseek-v4/goal-ab-20260731 (configs B and D).

The gather_qmm arm asserts its own output shape rather than trusting it: this
box's ledger records the [rows,K] vs [rows,1,K] calling-convention trap twice,
where a broadcast silently does g times the work and still returns plausible
numbers.  x is [g, rows, per] -> [g, rows, r], checked in code and in a test
that forces the wrong shape through.

The cache is keyed on the identity of the quantised tensors, so load_weights /
update / set_dtype invalidate it instead of serving a stale dense copy, and it
hangs off a plain object so it never enters the module's parameter dict.

Adapted from the original decode-lane commit: the attribution paragraph and the
matching docstring notes are new, and replace framing that let a reader take the
byte count for the speedup.  A Metal A/B did not support that reading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nce does

The reference keeps the whole attention lane at the model dtype and uses fp32
only as arithmetic, never as storage:

  * apply_rotary_emb rotates x.float() and copies the result back into the
    caller's own bf16 tensor (inference/model.py L234/L243);
  * the compressor pools in fp32 — "compression need fp32", L321-322 — but
    casts the row back before the norm (`kv = self.norm(kv.to(dtype))`, L362),
    and rotate_activation then *asserts* the row is bf16 (L249);
  * sparse_attn is declared q/kv/o: BF16 with FP32 accumulator fragments, and
    casts the probability block to BF16 before the PV gemm (inference/kernel.py
    L295-297, L305, L340).

This backend stored all three in fp32.  Because mx.concatenate and mx.matmul
promote, one fp32 tensor was enough to pull everything after it up: the roped
per-position KV promoted the window cache, the compressor's rows promoted the
concatenated attention tensor, the fp32 probabilities promoted `o`, and an fp32
`o` made the o-LoRA einsum upcast wo_a as well — 128 MiB of fp32 weight
materialised per layer per token.  From there the residual stream, the MoE and
every projection ran fp32 activations, on every layer, not only compressed ones.
The promotion starts at _apply_interleaved_rope on layer 0 and never unwinds.

The three storage points now follow the reference.  Measured on the shrunk
fixture: the arithmetic gap between the arms is one bf16 ulp on a
compressor-free layer (5.4e-3) and the compressed rows differ from the fp32 arm
only by the cast.  End to end at bf16 the gap is much larger, and the cause is
discrete rather than arithmetic — a bf16-sized nudge flips which expert the
gate picks for a few near-tied tokens — so the tests bound the arithmetic with
the router taken out and exhibit the flips instead of pretending the end-to-end
number is a quality signal.  The real quality gate is a task eval on the real
checkpoint.

This is the decode win in this lane, and the measurement says so with the
o-LoRA arm held fixed: on the real 2bit-DQ checkpoint, in one guarded window,
AR 4.534 -> 15.954 tok/s = 3.52x, and K=3 speculative 9.530 -> 25.856 tok/s.
The o-LoRA cache moves 4.534 -> 4.627 (+2.1%, inside drift) on its own, so
essentially all of the 3.5x is here.  Receipts:
bench/deepseek-v4/goal-ab-20260731 (configs B, D, A).

It is also what costs spec==AR byte-identity.  At fp32 storage the precision
headroom absorbed the batch-width-dependent rounding of a K+1-wide verify
forward; at bf16 it no longer does, so the difference reaches the argmax on
near-tied tokens.  Every divergence in the goal window was inspected and each is
a semantically equivalent completion at a near-tie, not corruption; the same
mechanism separates bf16-AR from fp32-AR, so it is not a spec-lane regression.
The paired task eval on the real checkpoint is what settles it (HumanEval
86.0/81.7 candidate vs 85.4/82.3 control, McNemar p=1.0 on both metrics —
bench/deepseek-v4/quality-eval-20260731).  MTPLX_DSV4_FP32_ACTIVATIONS=1
restores byte-identity for anyone who needs the diagnostic lane.

No golden and no tolerance moved: every cast here is a no-op at fp32, which is
where both parity goldens and the streaming-decode oracle were captured, and
that is asserted with mx.array_equal rather than assumed.

Adapted from the original decode-lane commit: the attribution and the
byte-identity paragraphs are new, written after the Metal A/B and the paired
task eval that the original was written before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Arm (b) tracks the dense einsum to ~1e-6 against fp32 activations, which is
what the existing tolerance gate measures.  Against bf16 activations the CPU
kernel loses two orders of magnitude (~1.3e-2 on the o-LoRA output alone),
while the dense bf16 einsum stays near one ulp — so the arm the shipped gate
covers is not the arm serving will run.

Metal's gather_qmm is a different kernel with fp32 simdgroup accumulation, so
this is the pessimistic end rather than a prediction.  Pinning it here so the
GPU window re-measures it deliberately instead of discovering it, and so a
future reader does not read "tolerance gate green" as "arm (b) is free".

The GPU window has since run.  On Metal at bf16 the arm is both faster and
lighter than the shipped default — AR 16.146 vs 15.954 tok/s (+1.2%), K=3
26.762 vs 25.856 (+3.5%), peak 94.31 vs 96.97 GiB, i.e. it gives back the whole
+2.69 GiB the dense cache costs — and its committed text on the goal prompt
diverged from the default arm's only at the same near-tie every other arm in
that window landed on.  That is not damage, but it is also not a quality
result: one 256-token prompt does not gate an accumulation-order change.  The
CPU bound below stays as the pessimistic marker until arm (b) has a task eval
of its own; it stays off by default until then.
(bench/deepseek-v4/goal-ab-20260731, config C.)

Adapted from the original decode-lane commit: the Metal paragraph is new — the
original was written before that window ran.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…directly

DeepseekV4MTP subclasses DeepseekV4DecoderLayer, so the draft head runs the same
DeepseekV4Attention and inherits both of the preceding changes by construction.
Inheriting is not proving.  Every gate shipped so far builds a model with no
draft block and drives `model.layers` — which the draft block is not in — so the
one attention instance that reaches the accept/reject comparison was the one
instance nothing covered.

Two risks are specific to it, and both are now measured rather than argued:

  * cross-instance cache service.  _DerivedCache is keyed on the identity of the
    quantised tensors, so trunk and draft have no shared keyspace to collide in.
    That is an argument about the code; the gate builds five attentions (four
    trunk + one draft), populates all five through both entry points, and checks
    each holds a dense tensor equal to *its own* dequantised wo_a — plus that no
    two of the five are equal, so the check cannot pass vacuously.  Making the
    cache a module-level singleton fails it, as does making the cache store a
    rounded copy (which fails the mtp_forward bit-identity gate).
  * a half-applied dtype arm.  An fp32 draft against a bf16 target would make
    every verify a cross-dtype comparison without anything failing.  The gates
    assert draft logits and the draft block's own KV window at bf16, that
    MTPLX_DSV4_FP32_ACTIVATIONS reaches the draft path too, and that at fp32 the
    two arms are bit-identical through mtp_forward.  Pinning _store_dtype to
    fp32 fails the bf16 gate.

o_lora: +4 (mode pickup and distinct cache objects, bit-identity through
mtp_forward incl. the cache-hit call, per-instance cache contents,
cache invisible to parameters()/strict load under the mtp.0.* paths).
dtypes: +3 (bf16 storage, fp32 escape hatch, fp32 bit-identity).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bf16 one

The harness failed a run on any spec-vs-AR divergence.  That was right when it
was written -- every activation was stored in fp32 and byte identity held -- and
it is wrong now, for a reason that is structural rather than a regression.

Draft and verify are batch-shaped forwards, so the committed row's KV is
projected inside a K+1-wide GEMM rather than alone.  The backend's documented
invariant has always been committed-sequence exactness, not bitwise-identical
logits.  At fp32 storage the precision headroom absorbed the width-dependent
rounding; at the bf16 default it reaches the argmax on near-tied tokens.  So on
the lane that actually serves, a divergence is a measurement of how often a
near-tie routes differently on one prompt -- and a harness that turns that into
exit 1 is rendering a quality verdict from a 256-token sample, which is not what
settles quality here.  Every divergence in the goal window was inspected and each
was a semantically equivalent completion, several of them the same alternative
text different arms independently landed on.

So the comparison is always run and always recorded; only the exit status moves:

  * MTPLX_DSV4_FP32_ACTIVATIONS=1 -- the diagnostic lane where identity does
    hold.  Hard gate, exit 1, unchanged.
  * bf16 storage (default) -- divergences are DATA: count, compared length,
    first index and both tokens, in the log line, the summary column and the
    JSON.  The receipt also carries which lane ran and whether identity was
    enforced, so a status-0 receipt can never be read as "spec==AR held" when it
    was never asked to.
  * --require-exact -- restores the hard gate on any lane.

The count is new and is the part that carries information the first index does
not: one near-tie both arms recover from and a rollback that desyncs and never
re-converges have the same first index and nothing else in common.  Positions
past the shorter sequence count as divergent, so a truncated arm cannot look
identical by ending early.

The in-repo spec gates are unaffected and that is now asserted rather than
assumed: their shrunk model never calls set_dtype, so it is all-fp32 and every
cast the storage fix introduces is a no-op there.  31/31 stay green at the bf16
default; the new test pins the premise.  Six gates cover the policy itself --
the two env parsers agreeing, the enforcement decision on both lanes and under
--require-exact, the divergence shape, the truncation case, and the rendering
that keeps a reported run visually distinct from a gated one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@davidtai

davidtai commented Aug 1, 2026

Copy link
Copy Markdown
Author

Decode path: reference-faithful bf16 activation storage + o-LoRA weight modes

Two changes on top of the speculative lane, plus the gates and the receipts.

What it is

1. Activation dtype (60f41d5) — this is the speed. The backend stored three
activations in fp32 that the reference stores at the model dtype. Because
mx.concatenate and mx.matmul promote, one of them was enough: the promotion
starts at _apply_interleaved_rope on layer 0 and never unwinds, dragging the KV
cache, both attention matmuls, the o-LoRA einsum (which then upcasts wo_a — 128
MiB of fp32 weight per layer per token) and the entire residual stream up with it,
on every layer. bf16 is the reference's behaviour, not a quantisation of it:

  • apply_rotary_emb rotates x.float() and copies the result back into the
    caller's own bf16 tensor — inference/model.py L234/L243
  • the compressor pools in fp32 ("compression need fp32", L321-322) but casts the
    row back before the norm, kv = self.norm(kv.to(dtype)) L362 — and
    rotate_activation then asserts the row is bf16, L249
  • sparse_attn is declared q/kv/o: BF16 with fp32 accumulator fragments, and
    casts the probability block to BF16 before the PV gemm — inference/kernel.py
    L295-297, L305, L340

Softmax stays fp32 (the reference keeps acc_s/scores_max/sum_exp in fp32
fragments); only storage moved. MTPLX_DSV4_FP32_ACTIVATIONS=1 restores the old
path as an A/B arm. Every cast is a no-op at fp32, which is where both parity
goldens and the decode oracle were captured — asserted with mx.array_equal, so no
golden and no tolerance moved.

2. o-LoRA weight modes (2dd7a12). wo_a is static ([8192, 4096]) and
_o_lora re-ran mx.dequantize on it every call — per token, per layer, 43 layers.
MTPLX_DSV4_O_LORA now selects: cached (default, dequantise once; bit-identical,
gated with mx.array_equal through both the trunk and the MTP draft block),
dequant (the old behaviour, kept as control and oracle), gather_qmm (the
quantised block-diagonal matmul the reference flags and declines at L538-539;
env-gated, not bit-identical). Cache is keyed on the identity of the quantised
tensors, so load_weights/update/set_dtype invalidate it, and it hangs off a
plain object so it never reaches the weight tree.

Honest attribution: the cached dequant is not where the speed came from.
Measured at fp32 storage it is 4.534 → 4.627 tok/s AR (+2.1%), inside this box's
15-20% cross-window drift — not distinguishable from zero, because at fp32 the
einsum promotes wo_a anyway. It costs +2.69 GiB resident. It ships because it is
bit-identical and removes real redundant work. bf16 storage carries the 3.5×.

Speed (bench/deepseek-v4/goal-ab-20260731)

Real 2bit-DQ checkpoint + mxfp4/bf16 MTP bank, B=1, greedy, 328-token code prompt,
256 decode tokens, capture_commit/stock/committed, 8-token unrecorded warmup.
All rows below are in-window (one guarded window, arms interleaved B→A→C so
drift pushes both controls the same way).

config o_lora acts arm tok/s ms/tok peak GiB
B (baseline) dequant fp32 AR 4.534 220.6 95.17
B dequant fp32 K=3 9.530 104.9 95.18
A (shipped default) cached bf16 AR 15.954 62.7 96.97
A cached bf16 K=1 22.634 44.2 96.97
A cached bf16 K=2 25.646 39.0 96.98
A cached bf16 K=3 (best) 25.856 38.7 96.98
A cached bf16 K=4 22.633 44.2 96.98
A cached bf16 K=5 21.704 46.1 96.98
C (env-gated) gather_qmm bf16 AR 16.146 61.9 94.31
C (env-gated) gather_qmm bf16 K=3 26.762 37.4 94.30

AR 4.534 → 15.954 = 3.52×; K=3 9.530 → 25.856. Stacked with speculative
decode, baseline to best shipped-default arm is 5.70×. Config B reproduces the
pre-change window exactly (same tok/s, same committed-text sha, same accept counts),
so the baseline is anchored rather than asserted. gather_qmm is a strictly better
speed and memory point (+1.2% AR / +3.5% K3, −2.66 GiB — it gives back the entire
cache cost) but its quality is unproven, so it stays off by default.

Quality (bench/deepseek-v4/quality-eval-20260731)

Paired, same prompts, same decode config, candidate (bf16) vs fp32-activation
control, EvalPlus, greedy:

arm dataset n base plus
control (fp32) HumanEval 164 85.4% 82.3%
candidate (bf16) HumanEval 164 86.0% 81.7%
candidate (bf16) MBPP 378 84.1% 66.9%
control (fp32) MBPP 378 pending pending

Exact McNemar on the discordants:

  • HumanEval base: 85.4% → 86.0% (+0.6 pts), b=7 c=8, p = 1.0000
  • HumanEval plus: 82.3% → 81.7% (−0.6 pts), b=9 c=8, p = 1.0000

Discordants are symmetric in both directions (7↔8, 9↔8) — this is noise, not a
shift.

The near-tie disclosure

spec ≡ AR byte identity does not hold at bf16 defaults, and that is disclosed
rather than papered over. The mechanism is structural, not a rollback bug: draft
and verify are batch-shaped forwards, so the committed row's KV is projected inside
a K+1-wide GEMM rather than alone. The backend's documented invariant has always
been committed-sequence exactness, not bitwise-identical logits. At fp32 the
precision headroom absorbed that difference; at bf16 it reaches the argmax on
near-tied tokens.

Every divergence in the goal window was inspected. They are semantically equivalent
completions at near-ties — for name, deps vs for package_name, deps; two
equivalent ways to compute remaining; an inlined cycle walk vs a helper call —
and the same mechanism separates bf16-AR from fp32-AR, so it is not a spec-lane
regression. Several distinct arms land on the same alternative text. The fp32
arms (config D) still pass byte-identity at K=2 and K=3, exactly as before.

16f604b reframes the harness accordingly: the comparison always runs and is always
recorded, only the exit status moves. Byte identity stays a hard gate under
MTPLX_DSV4_FP32_ACTIVATIONS=1 (the diagnostic lane where it does hold) or
--require-exact; at bf16 defaults divergence is reported as data — count, compared
length, first index, both tokens — in the log, the summary column and the JSON. The
receipt records which lane ran and whether identity was enforced, so a status-0
receipt can never be read as "spec==AR held" when it was never asked to.

The in-repo spec gates are unaffected and that is now asserted rather than assumed:
their shrunk model never calls set_dtype, so it is all-fp32 and every cast is a
no-op there. 31/31 stay green.

Acceptance criterion

Per the maintainer's ruling for this lane: byte-exactness is not the bar; a
measured-minimal quality delta is.
On that bar this clears — McNemar p=1.0 on both
HumanEval metrics with symmetric discordants, for 3.52× AR and 2.7× at K=3 — with the
fp32 escape hatch retained for anyone who needs byte identity as a diagnostic.

Gates

139 deepseek_v4 tests green (was 101): decode 7, dtypes 13, indexer 14, loader 3,
mtp 21, new_math 7, o_lora 19, parity 2, spec 37, swiglu_clamp 16. Upstream-touched
coverage 189/189. scripts/hygiene_scan.sh exit 0.

The MTP draft block reuses DeepseekV4Attention, so it inherits both changes by
construction — and is gated for it directly (656bc96) rather than by inheritance
argument: five attention instances each hold their own dequantised wo_a (not a
neighbour's, and no two equal, so the check can't pass vacuously), cached == dequant
is bit-for-bit through Model.mtp_forward including the cache-hit call, and the draft
path holds bf16 storage plus the fp32 escape hatch. A module-level singleton cache, a
rounded cache copy, and a fp32-pinned _store_dtype each fail these gates.

@davidtai

davidtai commented Aug 1, 2026

Copy link
Copy Markdown
Author

Quality follow-up: MBPP paired result (completes the bf16 verdict)

The paired MBPP control arm from the previous comment. Same setup — bf16 candidate vs the fp32-activation control, EvalPlus, greedy, same prompts and decode config.

arm HumanEval+ base/plus (n=164) MBPP+ base/plus
control (fp32) 85.4 / 82.3 88.5 / 73.2
candidate (bf16) 86.0 / 81.7 88.0 / 71.6

Exact McNemar on the discordants (b = control-pass/candidate-fail, c = the reverse):

  • HumanEval base: +0.6 pts, b=7 c=8, p = 1.0000
  • HumanEval plus: −0.6 pts, b=9 c=8, p = 1.0000
  • MBPP base: −0.5 pts, b=5 c=4, p = 1.0000
  • MBPP plus: −1.6 pts, b=9 c=6, p = 0.6072

Every cell's discordants are near-symmetric — bf16 reshuffles a handful of near-ties in both directions with no net damage, which is the signature the byte-gate near-ties predicted and the opposite of a regression. Worst case is −1.6 points against a >3-point failure bar; nothing significant, nothing cratered. On the "minimal quality delta" bar, bf16 passes on both benchmarks.

One honest bound: the MBPP control arm is scored on the paired first 183 of 378 problems — it was stopped early to return a shared GPU to production serving, and the pairing is exact (candidate scored on the same 183 for this comparison; its full-378 absolutes were 84.1 / 66.9). HumanEval is final at 164. The MBPP discordant balance (b/c within one of each other on both metrics) is not close to the threshold where the remaining problems could flip the verdict, but I'll post the full-378 McNemar when the control arm resumes on an idle window, for completeness.

Receipts: bench/deepseek-v4/quality-eval-20260731-VERDICT.txt (+ -ANALYSIS.json, per-arm sample/eval trees). gather_qmm (the env-gated MTPLX_DSV4_O_LORA=gather_qmm mode, +3.5% K3 / −2.66 GiB) was not quality-tested here and stays off by default; its task-eval arm rides a later window.

davidtai and others added 3 commits August 1, 2026 05:22
The measured cycle is 84.8 ms fixed + 8.9 ms/K with the target forward
71-81% of it, so what is left is the number of kernels the host encodes
per token.  Measured on the Metal dispatch stream itself (see the census
scripts in the following commit): one bf16 s==1 step was 19,809
dispatches in 384 command buffers, with host encode at 56-59 ms against
32-35 ms of GPU execution.  The encode is exposed, not hidden.

Two thirds of it was the Hyper-Connection Sinkhorn chain: 20 alternating
row/column normalisations on a 4x4 tensor, 87 times per token, each pass
costing sum + add-eps + divide as three separate dispatches.  Three
changes, none of which touch the arithmetic: the fp32 casts of
fn/base/scale are derived once instead of per call (fn is 24 x 16384 on
the real model, cast on every one of those 87 calls); the three affine
transforms become one `mixes * scale_vec + base` over the whole row; and
the whole of pre/post/head becomes a module-level pure function of arrays
so mx.compile can hold ONE tape shared by every Hyper-Connection module.
vs_Add 3570 -> 43 and g2_Divide 3408 -> 11 per step, replaced by 3354
fused dispatches.  This is not the whole-forward compile lever that is
dead on this box -- that one lost because the kernels it fused were
already bandwidth-bound; here they are 4x4.

The attention's hand-rolled fp32 softmax becomes ordinary attention: one
all-zero KV row appended (its raw score is therefore exactly 0, and being
also the V row it contributes exactly nothing to the numerator) carrying
the per-head attn_sink as an additive column, then one
mx.softmax(precise=True).  Worth 4 dispatches per layer at bf16, and it
stops materialising both full-size fp32 temporaries -- ~16 bytes of
transient per score element down to 6, which is ~670 MB per compressed
layer at a 1024-token prefill chunk.

19,809 -> 14,639 dispatches (-26.1%), 384 -> 288 command buffers
(-25.0%); 17,733 -> 13,039 (-26.5%) at fp32.

mx.fast.scaled_dot_product_attention is wired as a third arm and takes
sinks= natively, but its Metal kernels are only instantiated for head
dims 64/96/128/256 (0.31.2) and 64/96/128/192/256 (0.32.x) -- verified
against each shipped mlx.metallib -- and this MLA latent is 512 wide, so
it takes MLX's own unfused fallback on every version on this box.  It is
still the cheapest measured arm (-215 dispatches/step) and is kept, gated
exact, behind MTPLX_DSV4_ATTN=sdpa.

Both levers are A/B-able (MTPLX_DSV4_HC_COMPILE=0, MTPLX_DSV4_ATTN=dense
restore the previous behaviour exactly) and gated against the paths they
replace, not against a golden: _hc_pre_impl is bit-identical to the
reference transcription it collapses, the compiled tape is bit-identical
to the eager one at decode shape, and the attention arms track the dense
oracle at 1.9-2.4e-6 with argmax exact over 204 streaming decode steps in
both the dense and the indexer-filtered regime.

Not available and now written down: the Sinkhorn comb matrix cannot be
precomputed at load.  The reference derives it from the layer's own
hidden state (mixes = F.linear(x, hc_fn) * rsqrt, Block.hc_pre), which is
also why hc_fn is [24, hc*dim] in the checkpoint rather than [hc, hc].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit c421ba59e6cb97f63828b0dda4c206bb64d92e8b)
…ing it

Two censuses, because they answer different questions.

deepseek_v4_dispatch_census.py is the measurement of record: it runs the
workload under the instrumented MLX build in mlx-profiler and counts the
kernels Metal is actually asked to run, per name, with the host-encode
and GPU-execution interval of every command buffer.  A single trace
cannot say what one decode step cost -- it covers the whole process -- so
the driver runs the same prefill twice, once with 1 decode step and once
with 9, and reports the difference over 8.  Load, prefill and compile
tracing cancel exactly, and the counts reproduce to the row across runs
where the timings (shared box) do not.  It refuses a trace whose summary
reports dropped rows or complete:false.

deepseek_v4_op_census.py counts graph primitives via mx.export_to_dot
instead.  Strictly coarser -- MLX services some primitives by rewriting
strides and fuses others -- but it needs no instrumented build, runs on
CPU, and breaks the step down per component (one HyperConnection.pre
call, one attention call per layer type), which a whole-step dispatch
trace cannot separate.  Use it to find where the dispatches are; use the
other to say what removing them is worth.

Both run the shrunk seeded config with DeepSeek-V4-Flash's structural
constants restored -- 43 layers, the shipped compress-ratio pattern,
hc_mult 4, 20 Sinkhorn iterations -- because dispatch count follows the
structure, not the widths, so a 32-wide model has the same dispatch
stream as the 4096-wide one and costs nothing to run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit e5887fbd98711767d28a3cf45d33e8946ca05da8)
The dispatch census each lever beats and the measured B=1 window on the real
2-bit-DQ checkpoint: dense/hc0 -> fused/hc1 (default) is AR 17.37 -> 22.80
(+31.3%) and K=3 26.31 -> 30.88 (+17.4%), i.e. 13.7 ms/token of host encode
removed -- 91% of the ~15 ms the census attributed to the ~5,200 removed
dispatches, so on the real model dispatch removal is wall-clock.  The mlx-0.32
arm is a clean null (host-encode gone, decode is GPU-forward bound not
qmm-ALU bound) and is not for the SDPA fusion: no MLX on this box instantiates
a head dim of 512.  Scope is honest -- the remaining gap to 40 tok/s is
GPU-forward/verify-width, not dispatch.  Deferred items carry their measured
size, including the one that would take the remaining Sinkhorn reductions from
3,678 per step to 87 and is deliberately not attempted here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit e359c6f4532c7e3c0011df1556e0f39f4cf0fd9e)
@davidtai

davidtai commented Aug 1, 2026

Copy link
Copy Markdown
Author

Decode dispatch reduction: fused CSA attention + Hyper-Connection tape collapse

Two structural levers on the DeepSeek-V4 decode path, both env-gated with the fast
path on by default, both measured on the real 2-bit-DQ checkpoint.

(A) Fused CSA attentionattn_sink becomes one all-zero-V extra KV column so
the per-head sink logit rides an ordinary softmax denominator, and the whole block
is a single mx.softmax(precise=True) (fp32 max + fp32 accumulate, bf16 in/out).
This drops the materialized fp32 score block the dense path wrote twice over.
MTPLX_DSV4_ATTN ∈ {dense,fused,sdpa}, default fused. The sdpa arm is kept and
gated exact but is not default: MLX only instantiates fused SDPA kernels for
head dims 64/96/128/(192)/256, and DeepSeek-V4's MLA latent is 512 wide, so on
every MLX on this box sdpa takes the unfused fallback — fused is that fallback
minus two copies.

(B) Hyper-Connection / Sinkhorn tape collapse — derive the fp32 fn/base/scale
once (not per call), fold three affines into one, and run the whole pre as a
module-level pure function so mx.compile holds one tape shared across all 87
HC modules. MTPLX_DSV4_HC_COMPILE, default 1, with _HC_COMPILE_MAX_ROWS=32
(bounds MLX's tape-cache scan and the >32-row precision drift).

Why dispatch count ≈ wall clock here

mlx-profiler census, per bf16 decode step:

dispatches command buffers
before 19,809 384
after (default) 14,639 288
−26.1% −25.0%

The load-bearing finding: host encode is exposed — 56–59 ms of encode against
32–35 ms of GPU execution, ~2.9 µs/dispatch, size-independent. So removing
~5,200 dispatches removes host encode roughly one-for-one.

Measured window (bench/deepseek-v4/kernel-a-ab-20260801)

B=1 greedy, 328-tok prompt, 256 decode, drift-bracketed, MTPLX_DSV4_O_LORA=cached
held fixed:

arm AR tok/s K=3 spec tok/s
before (dense, hc0) 17.37 26.31
after (fused, hc1) 22.80 (+31.3%) 30.88 (+17.4%)

+5.43 AR tok/s = 13.7 ms/token removed, 91% of the ~15 ms the census projected.
The mlx-0.32 arm is a clean null — once host encode is gone, decode is GPU-forward
bound, not qmm-ALU bound. No regression; all arms' decode text stayed coherent.

Scope (honest)

This trims host encode only. The remaining gap to the ≥40 goal (K=3 30.9 → 40 =
1.29×) is GPU-forward / verify-width, not dispatch — a different lever.

Two deferrals worth flagging, neither done here:

  • Sinkhorn is activation-dependent, so comb cannot be precomputed at load
    (mixes = F.linear(x, hc_fn)·rsqrt from the layer's own hidden state; that's
    also why hc_fn ships [24, hc·dim]).
  • The Sinkhorn reduce_sums are the floor: 3,678 of the remaining 14,639
    dispatches (25%). mx.compile doesn't fuse reductions; only a hand
    mx.fast.metal_kernel doing the whole 4×4 chain would take those 3,678 → 87.

Gates

tests/test_deepseek_v4_*.py: 163 passed (139 pre-existing + 24 new in
test_deepseek_v4_kernel_paths.py), green in all six arms
{fused,dense,sdpa} × hc_compile{0,1}. Coverage includes fp32 parity vs the
dense oracle (one-shot + streaming, dense + sparse regimes, and the
MTPLX_DSV4_FP32_ACTIVATIONS=1 escape hatch), compiled==eager bit-identity at
decode shape, and a weight-rebind mutation guard on the static HC cache. Census is
reproducible via scripts/deepseek_v4_dispatch_census.py.

davidtai and others added 5 commits August 1, 2026 06:49
The Hyper-Connection Sinkhorn loop is the decode step's remaining reduction
floor after the HC-compile tape: 39 reduce_sum + 39 fused-divide + 1 softmax
dispatch per `pre` call (86 calls/token), all on a [.., 4, 4] tensor that is 16
floats at decode. mx.compile does not fuse reductions and no stock op does 20
alternating normalisations in one launch, so it cannot be removed with a stock
op — but the whole 40-pass schedule is a deterministic recurrence that a hand
kernel runs in one launch.

`hc_split_sinkhorn`/`_hc_pre_impl` now route the loop through
`_sinkhorn_normalise`, which dispatches to either the stock ops (`_sinkhorn_ops`,
byte-identical to the loop it replaces — the default and the parity oracle) or
`_sinkhorn_kernel_apply`, one `mx.fast.metal_kernel` per call, one thread per
matrix, the normalisations in registers. Env-gated off
(`MTPLX_DSV4_SINKHORN_KERNEL=1`) until a real-weights window confirms tok/s.

Bit-identical: the direct [.,4,4] comb matches the stock loop at 1e-6, argmax
exact (max|d| <= 1.8e-7); composes with the HC-compile tape (compiled == eager,
exactly); end-to-end logits argmax-exact over prefill + 99 decode steps.

Census (shrunk config, bf16, per decode step): total ops 14,639 -> 7,845
(-6,794); the ~3,354 Sinkhorn reduce_sum + ~3,354 fused-divide + 86 softmax
dispatches collapse to 86 kernel dispatches; command buffers 288 -> 155;
profiler-build host-encode 42.0 -> 22.0 ms/step (shared-box, indicative).
Queued-lane: 7 us/call vs 470-640 us/call for the stock 42-dispatch loop.

Tests: tests/test_deepseek_v4_sinkhorn_kernel.py (12, GPU-gated); full
deepseek_v4 suite 165 passed / 5 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…/size alone

_sinkhorn_normalise chose the Metal kernel on dtype + hc size only, so with
MTPLX_DSV4_SINKHORN_KERNEL=1 any CPU forward (the CPU-pinned test suites, or any
CPU device path) dispatched a mx.fast.metal_kernel on the CPU device and raised
"[metal_kernel] Only supports the GPU". The gate's own docstring already promised
CPU/no-Metal falls to _sinkhorn_ops, the bit-identical stock-MLX oracle; it just
never checked the device.

Add the device guard the MLA kernel already uses -- mx.metal.is_available() and
mx.default_device() == mx.gpu -- to the eligibility check, restoring the
documented CPU fallback. The GPU path (default device == gpu) is unchanged: the
kernel still fires and stays bit-exact to the oracle (1.19e-7).

Tests (tests/test_deepseek_v4_sinkhorn_kernel.py):
- test_cpu_device_falls_back_to_ops_flag_on: flag ON + CPU device -> no
  ValueError, output bit-identical to _sinkhorn_ops, no kernel built (the
  regression test the bug lacked).
- test_cpu_full_forward_flag_on_matches_flag_off: full tiny-stack CPU forward,
  flag on == flag off, bitwise.
- test_gpu_device_routes_to_kernel_flag_on: positive side of the gate -- on GPU
  the kernel actually engages (cache populated) and is bit-exact; fallback alone
  could not be told from the kernel by value.
- test_env_flag_parses: made hermetic in its own env var (save/clear before the
  default-OFF assertion) so a globally-forced MTPLX_DSV4_SINKHORN_KERNEL=1 run no
  longer trips the default read. Root-cause fix, not xfail.

deepseek_v4 suite: flag-forced-CPU 165 passed (was 94 failed), clean-env 165
passed (162 prior + 3 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@davidtai

davidtai commented Aug 1, 2026

Copy link
Copy Markdown
Author

Stage 4 — shape-fixed Sinkhorn kernel: +29.3% AR, 32.5 tok/s K3

This stage targets DeepSeek-V4's actual Hyper-Connection shape rather than a generic reduction: hc=4, a [4,4] fp32 comb, exactly 20 alternating normalization iterations, and 86 pre calls per decode step. The stock graph launches 39 reductions, 39 divides, and one softmax per call. The Metal lane keeps the same operation order in registers and emits one kernel per call.

The port is opt-in (MTPLX_DSV4_SINKHORN_KERNEL=1) and remains off by default. Route selection is now installed once when HyperConnection is constructed: CPU/no-Metal explicitly installs the stock callable; the GPU lane accepts only hc=4, iters=20, eps=1e-6 and fails before generation otherwise. The enabled token path has no environment/device/eligibility checks and no silent fallback. Fused attention remains the default.

Real-weight end-to-end window

One guarded load, B=1 greedy, 328-token prompt + 256 generated tokens, 8-token warmup, capture_commit / stock verify / committed history. Model: DeepSeek-V4-Flash 2-bit-DQ trunk plus the lossless mxfp4/bf16 MTP bank. Held fixed: cached o-LoRA, HC compile on, fused attention. M5 Max, macOS 26.5.2, MLX 0.31.2.

arm AR tok/s K3 tok/s K3 committed/verify peak GiB
control: fused + Sinkhorn off 22.318 30.440 2.844 96.89
Sinkhorn kernel on 28.858 32.497 2.723 97.12
control repeat / drift bracket 22.881 30.903 2.844 96.89
  • AR: 22.318 → 28.858 = +29.3% against the first paired control, or +27.7% against the two-control mean. Pure AR now clears the DS4 27 tok/s comparison point.
  • K3: 30.440 → 32.497 = +6.8% in this window; 32.497 tok/s is the best K3 result in the matrix.
  • All outputs remained coherent (unique-line ratio 1.00, longest identical-line run 1). The kernel arm's spec != AR is the already documented bf16 near-tie diagnostic, not corruption; byte identity is not being claimed.

MLX-profiler attribution

This is structural evidence, not the end-to-end speed measurement above. In the profiler census, the Sinkhorn floor collapses 6,794 stock dispatches to 86 kernel dispatches; whole-step dispatches move 14,639 → 7,845, command buffers 288 → 155. The profiler build suggested 42 → 22 ms host encode, but only the guarded real-weight table above is used for the tok/s claim.

The benchmark ran on combined revision 3f0aa06; its Sinkhorn tree is identical to source revision 57f54dd, with the device guard from 8067f9b. Stage-4 ports that implementation and adds construction-time routing. The scrubbed immutable receipt records the command shape, arm data, provenance, drift, coherence, and SHA-256 manifest: DeepSeek-V4 Sinkhorn stage-4 receipt.

Gates

  • 163 DeepSeek-V4 tests passed with the flag unset on explicit CPU.
  • 163 passed with MTPLX_DSV4_SINKHORN_KERNEL=1 on explicit CPU, proving the stock CPU route remains safe.
  • 18 Metal tests collect, including GPU route engagement, extreme positive/negative fp32 combs, and one-ULP near-equal maxima at the existing <=1e-6 plus argmax-exact parity bar.
  • Diff checks, compileall, and Ruff passed. No dependency or lockfile change.

Stage-4 head: 6977736.

@davidtai

davidtai commented Aug 1, 2026

Copy link
Copy Markdown
Author

Shape-specific MLA disposition: rejected; no MLA code added to PR.

This was a clean, immutable local capture on one model load: the canonical raw
328-token Stage-4 prompt plus 256 decoded tokens, B=1 greedy, AR and K3,
capture_commit / stock / committed, with 8 warmup tokens. The profiler was
off during TPS measurement. Two fused Sinkhorn-off controls bracketed the MLA
candidates; all 14 measured arms completed 256 tokens and passed the
coherence, memory, and route-engagement gates. This is n=1 per arm/mode, so
there is no per-arm median; the control-bracket drift was 0.213% AR and 0.160%
K3.

The exact target workload is MLA at B1/H64/KV1/D512, bf16 storage and fp32
accumulation. A complete MLX-profiler geometry sweep chose HG8/TK8. That shape
reduces 64 KV reads to 8; it does not make a false 64-to-1 claim.

arm AR tok/s K3 tok/s
fused_sink0_control_a 22.9152 30.9666
generic_mla_sink0 22.4299 30.9738
shaped_mla_sink0 21.7033 30.9516
fused_sink1 28.9257 32.6521
generic_mla_sink1 28.5335 31.8245
shaped_mla_sink1 27.4052 31.7870
fused_sink0_control_b 22.8665 31.0161

Against the fused Sinkhorn-off control-bracket mean, shaped MLA + sink0 is
-5.188% AR / -0.128% K3, with AR parity only 11/256. Against fused + Sinkhorn,
shaped MLA + sink1 is -5.257% AR / -2.649% K3, with parity 221/256 AR and
81/256 K3. Both shaped and generic MLA are rejected and will not be ported.

The positive result is that fused + Sinkhorn remains the winner at 28.9257 AR /
32.6521 K3, independently corroborating the shipped Stage-4 receipt/comment
(whose official prior-best K3 was 32.497). This is not a new stage or success
claim.

For audit, local immutable receipt identifiers are: combined receipt SHA256
cf83d44ab958788c7e7bb591430fc7236eb5e4dc8c66e217607910da1ad4fcac; geometry
receipt SHA256
259315bd2bfa85627dc0e92eb7390deb2ca424f160f1ba106f7632e904d7cd35; capture-code
HEAD 206bb9799bf7a92fd55dbd97946211aa55ff7540; canonical prompt SHA
ee94397faa812c91d5f1a0ee17c5bb6ca6032883653591dd33d4cfddb737ac33. They are
local immutable receipt identifiers if not reachable upstream.

One failed first attempt is disclosed: it was a pre-model prompt-contract
rejection, produced zero benchmark rows, was fixed by restoring the exact
Stage-4 prompt, and is excluded from the performance data.

PR disposition unchanged: only Sinkhorn Stage 4 is proposed; MLA remains out.

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.

2 participants