DeepSeek-V4-Flash: native deepseek_v4 AR backend - #216
Conversation
…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)
…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>
Speculative decode on DeepSeek-V4-Flash: the MTP draft head, wired and measuredDeepSeek-V4-Flash ships one multi-token-prediction block upstream as 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. Benchmark
Per-depth acceptance:
Depth beyond 3This window ran K=1..3 only. A later sweep to K=5 ( 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 Building the model directorySpeculation needs a model directory that carries the draft head, which no published conversion does. The default Load the merged directory normally. Caveats
Tests
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. |
`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>
Decode path: reference-faithful bf16 activation storage + o-LoRA weight modesTwo changes on top of the speculative lane, plus the gates and the receipts. What it is1. Activation dtype (
Softmax stays fp32 (the reference keeps 2. o-LoRA weight modes ( Honest attribution: the cached dequant is not where the speed came from. Speed (
|
| 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.
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.
Exact McNemar on the discordants (b = control-pass/candidate-fail, c = the reverse):
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: |
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)
Decode dispatch reduction: fused CSA attention + Hyper-Connection tape collapseTwo structural levers on the DeepSeek-V4 decode path, both env-gated with the fast (A) Fused CSA attention — (B) Hyper-Connection / Sinkhorn tape collapse — derive the fp32 Why dispatch count ≈ wall clock heremlx-profiler census, per bf16 decode step:
The load-bearing finding: host encode is exposed — 56–59 ms of encode against Measured window (
|
| 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
combcannot be precomputed at load
(mixes = F.linear(x, hc_fn)·rsqrtfrom the layer's own hidden state; that's
also whyhc_fnships[24, hc·dim]). - The Sinkhorn
reduce_sums are the floor: 3,678 of the remaining 14,639
dispatches (25%).mx.compiledoesn't fuse reductions; only a hand
mx.fast.metal_kerneldoing 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.
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>
Stage 4 — shape-fixed Sinkhorn kernel: +29.3% AR, 32.5 tok/s K3This stage targets DeepSeek-V4's actual Hyper-Connection shape rather than a generic reduction: The port is opt-in ( Real-weight end-to-end windowOne guarded load, B=1 greedy, 328-token prompt + 256 generated tokens, 8-token warmup,
MLX-profiler attributionThis 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 The benchmark ran on combined revision Gates
Stage-4 head: |
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 The exact target workload is MLA at B1/H64/KV1/D512, bf16 storage and fp32
Against the fused Sinkhorn-off control-bracket mean, shaped MLA + sink0 is The positive result is that fused + Sinkhorn remains the winner at 28.9257 AR / For audit, local immutable receipt identifiers are: combined receipt SHA256 One failed first attempt is disclosed: it was a pre-model prompt-contract PR disposition unchanged: only Sinkhorn Stage 4 is proposed; MLA remains out. |
Adds a native MLX backend for DeepSeek-V4-Flash (
model_type: deepseek_v4), loading the publishedmlx-community/DeepSeek-V4-Flash-4bit/-2bit-DQcheckpoints directly throughmtplx serve. Nodeepseek_v4loader exists in mlx-lm; this is a from-scratch port of the referencedeepseek-ai/DeepSeek-V4-Flashinference code.What V4 adds over V3.2, and what this implements
index_topk.sqrtsoftplusscoring 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
n_comp > index_topkand cover the dense→sparse crossing mid-decode.scripts/.Known limits (documented in-code)
🤖 Generated with Claude Code