Skip to content

DeepSeek-V4.1-Flash on Metal: the EXL3 2.9 bpw lane - #2373

Draft
EugeneClaw wants to merge 92 commits into
ashhart:mainfrom
EugeneClaw:contribution/dsv41-exl3
Draft

EugeneClaw wants to merge 92 commits into
ashhart:mainfrom
EugeneClaw:contribution/dsv41-exl3

Conversation

@EugeneClaw

@EugeneClaw EugeneClaw commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

families/deepseek_v41 gains a second serving lane beside the existing release-checkpoint line: DeepSeek-V4.1-Flash from resident EXL3 packs on Apple Silicon, with the two Engram tables (~189 GB) streamed from disk by address. The two lanes coexist: the shipped line serves the release checkpoint (FP8 blocks, MXFP4 experts); this lane serves EXL3 2.9 bpw packs (quant_method: exl3 via requireCheckpoint) and dispatches its own *_exl3 kernel set. Same family, different checkpoint formats, no shared dispatch.

The lane is usable and exact today — every serving contract holds (drafted == plain, resumed == fresh, concurrent == solo, the kernel contract 89/89) — and it is submitted so the pieces upstream's line does not carry can be utilised: the EXL3 trellis-decode kernel family, streamed-Engram serving on a 256 GB machine, drafted decode with the retained prompt cache, and the exactness harness that gates all of it. Upstream's Metal line continues to move; this lane's development continues with it — the next axes are named under "Where this lane goes next" below.

On one 256 GB Mac (M5 Ultra): prefill 1,270 cold / 1,271 warm tok/s at 3,002-token prompts (931 before the plan-default fix); plain decode 44–50 tok/s; drafted decode 44.8 @4.5k / 54.9 @21k / 49.3 @62k; a resumed 21,966-token turn processes its 78-row suffix in 0.56 s. Context 131,072 receipted: the serving battery ran green at the window (cache resume 6/6, the standing table with every canon sha exact, same-boot determinism 5/5, the 329/329 band), and a single-turn session at 126,454 tokens — past the model's native window — read coherent and mechanically accurate with drafted decode at 37.7 tok/s. One number to read plainly: decode is one GPU's budget — roughly 50 tok/s drafted total at one lane. Concurrent lanes divide it (four cached chats: 59.9 tok/s aggregate, ~15 each); they do not multiply it. Every number below carries its context and was measured on this lane at this pack.

Checks on this head

Receipt (repo tools)

Run on the head above, one serve at a time on 127.0.0.1:8900, -Dcpu=apple_m1, 65,536 context, four lanes; raw JSON kept with the branch receipts.

  • Environment: head bec140d54; base main 5cfbc8880 (main has since moved to fcf85f7a5, with no file overlap against this diff); Zig 0.17.0; Metal, macOS; one M5 Ultra, 256 GB unified memory; checkpoint DeepSeek-V4.1-Flash-1017 EXL3 2.9 bpw (quant_method: exl3, pack 1.6.0, head_bits 6); Engram tables streamed from the same release.
  • Boxes it reaches: Apple Silicon with Metal. Run: one M5 Ultra, 256 GB. Not run: any other Apple chip, CUDA, HIP.
  • New switch? None added by this diff. The lane's 210 tuning and diagnostic switches keep their own readers and are listed under the lenient-switch section of docs/environment.md; projection_knobs.zig joins the checker's name-reader list. No defaults change.
  • Licence: MIT — the checkpoint's own LICENSE (Copyright (c) 2023 DeepSeek). The EXL3 pack is a quantisation of that checkpoint; this PR adds no weights.
  • Exactness, tools/bench_concurrent.py --levels 1,4 --tokens 256 --reps 3 --temperatures 0 --alone --serial: one stream 44.7 and 45.2 tok/s with alone 3/3 equal; four streams 65.1 and 64.2 aggregate with alone 12/12 equal, serial 1/1 equal, 0 failed, timing valid.
  • Decode, tools/bench_openai.py --tokens 256 --reps 3 --temperatures 0: 44.78 tok/s (fibonacci-raw) and 45.27 (gpu-chat-no-think), three runs each.
  • Prompt speed, tools/bench_cache.py --require-cold --tokens 64: first request 2,570 tokens with 0 cached, ttft 3.307 s; the repeat on warm pages 1.788 s. That first request pays the documented governor first-touch cold-page path; the 1,270 tok/s quoted above is the same 3,002-token prompt on warm pages.
  • Cache reuse: tools/bench_cache.py --require-reuse reports no state kept for the repeated single-turn request at that 2,570-token fixture (kept 0), so it qualifies false. This lane keeps a conversation's state for its next turn, not a re-sent identical prompt: the reuse receipt is the 21,966-token resumed turn, cached=21,888, 78 rows in 0.56 s.

The kernels (what runs)

Registered in the kernel contract at 89 variants for this family: 89/89 pass gates 1–4, 0 fail, 186/186 planted controls caught red.

  • EXL3 projection family (core/exl3_projection*.metal): the pack's trellis-coded weights decode in-kernel at use time — scalar register-only decode bodies (1–8 row windows, 16-warp FMA), tensor-unit MMA prompt tiles (tf_exl3_matmul_prefill_nax), matvec, int8 variants, and the codebook table. Bit-exact against the scalar reference at every served shape.
  • CSA2 sparse attention (csa2*.metal) and the sparse index block (index_exl3.metal): score tile, one-shot top-512 select (deterministic — the 10-second in-tree probe guards the select kernels' radix bookkeeping), candidate blocks, and the block filter.
  • Engram gather (engram_exl3.metal + host): rows read by address from the streamed tables — mmap for warm tables, positional + F_NOCACHE for cold ones, decided by a 64-sample latency probe (the governor; first-touch behaviour documented below).
  • Routed experts (route_exl3.metal, expert groups): gate/up as one indexed dispatch, down via the register-only body, shared experts fused.
  • Glue: the hyper-connection boundary pair, kv_glue (norm+RoPE+FP8 pack in one dispatch, bit-identical), the one-row transform fold, mul1 byte-sum decode, funnel word read.

The mechanisms (what it is, what it bought)

Context-labelled; every row is a receipt in the named round.

Decode path

Mechanism What it is Benefit
Parallel index merge (R-07) sparse-index heap walk split into filter + merge 3.8% on a 15k token
wo_a fusion (R-45) attention output projection's 8 slices in one indexed dispatch decode 12.9→14.3 tok/s
Identity selection (R-49) skip selection machinery when every key is kept 14.3→16.0 tok/s, bit-identical
Row-invariant split decode (R-47) a row sums identically inside a multi-row window the correctness landing that made drafted windows testable
mul1 byte-sum (R-310) codebook value path folds 4 byte sums into one 16-bit lane 18.3→18.8 tok/s, bit-identical
Register-only decode body (R-337) decoded weights stay in registers, one warp per tile 21.3→25.0 tok/s
FMA warps 16 (R-357) TF_DSV41_FMA_WARPS defaults 16 in the register body 32.3→35.9 tok/s
Shared-expert gate/up fusion (R-359) one indexed dispatch 36.0→38.3 tok/s
Multi-row dense decode (R-369) one weight decode serves all rows of a window 27.5→35.3 tok/s over two rounds
wq_a+wkv one dispatch (R-394) two attention projections share an input row 39.3→40.4 tok/s
Split-merge grid 4→16 (R-402) sparse split merge grid 39.9→41.6 tok/s, bit-identical
Indexer parallel finish (R-411) serial insertion sort made parallel prefill 1,274→1,295 tok/s
h8 register-only extension (R-425) shared down + Engram projection take the register body drafted band 39.3→41.6 tok/s
Index select one-shot (R-533) small-chunk index select in one dispatch resumed-suffix select 11.2→0.29 ms
Index weights_proj hoist (R-546) four k-steps hoisted, bit-identical index_q block 0.067→0.018 ms/token (3.7×)
kv_glue fused (R-486) KV norm+RoPE+pack 3→1 dispatches +1.4% plain, bit-identical
Pipelined forward commit (R-487) first two layers commit without waiting −0.69 ms/token (+2.85% plain)
Host Engram hash (R-488) the decode-row hash pass on the CPU +1.24% plain, byte-identical
Depth ratchet (R-494) adaptive depth ratchets shallow when deep rounds stop paying drafted band held, shallower rounds

Drafting

Mechanism What it is Benefit
Tap ring (R-321) drafter conditions on a rolling 128-row ring first drafted==plain receipt
128-row conditioning (R-385) drafter sees the trunk's full window band 23.0→34.2 tok/s; acceptance 12.7→42.1%
Adaptive depth policy (R-386/458) depth switch measured at the right row 34.7→36.5 tok/s; ships adaptive
Window-sized drafter (R-458) proposer runs depth+1 rows band 42.3→43.6 tok/s
Ring keep (R-398) rejected rows stashed and restored on rollback band agreement 76.7%→100%
Sampler (R-466) temperature/top-k/top-p/min_p + presence/frequency penalties, upstream-exact drafted==plain at the same seed, greedy and sampled

Prefill / long context

Mechanism What it is Benefit
Indexer score tile (R-190) 64×64 tiled MMA scoring prefill 738→772 tok/s
Funnel word read (R-346) routed prompt tile reads two words a lane 950.7→1,027 tok/s, bit-identical
Engram by address (R-353) mmap'd rows by offset replace positional reads host term 219→19 ms; prefill 1,184→1,268 tok/s
Residency probe + positional cold path (R-495) 64-sample probe calls the table cold; positional F_NOCACHE above 256k ids 35k first touch 68.4→36.4 s (+88%)
Mid-forward Engram staging (R-496) second layer's rows read while the GPU runs layers 0–13 21k first pass 987→1,237.7 tok/s
Block filter widening (R-501) candidate filter on only past 131,072 keys 41k stand +10.8%, 62k +19.3%, shas unchanged
Tensor-unit prompt kernels (upstream's plan) the routed prompt GEMM at tensor units 36.7→60.5 tok/s integrated (2026-10-24 receipts)

Concurrency, cache, memory

Mechanism What it is Benefit
Retained prompt cache (R-471+) V4.1 resident serving keeps conversation states resumed 21,966-token turn: 78 rows in 0.55 s (cached=21888); 30.5× page-warm
Tail marks (R-472/474) one off-grid state at the pass's last tile, ≥9 rows after the cut every turn resumes at its own boundary; the lane-gated form (tail_align > 1)
One-row batch (R-531/532) lanes share one-row windows, batch on by default +24.6% @2 lanes, +28.6% @4; 59.9 aggregate on cached sessions (receipted on this tree)
Window groups at 8 lanes (R-535/536) four-window groups instead of 8-row rounds 52.8→53.2 aggregate; 32/32 token-identical; zero degenerate replies
Sampled concurrent==solo (R-543) shared rounds exact under sampled draws 12/12 at temperature 0.7, seed 1234
Adaptive Engram gather (R-462/463) 8-worker mapped path only when pages are cold; whole-window page-in + latch cold gather 3.02→1.05 ms; warm band untouched

Priced out (so the next person doesn't re-derive): expert affine frontier (3-bit misses the 1.05 quality band at every group size; 4-bit needs 276 GB), int8/E4M3 repack (the decode window is priced out; prefill-only ceiling ~1,343), comparator kernel ports (losses on our tile), the mHC fold (≥20× serialisation for 6 µs), the prompt merge (moves the reply), 8-row rounds (priced negative; window groups won), tree drafts, glue-dispatch mux (6–10 µs/dispatch is the unit price; the measured lever left standing).

Exactness receipts (the contracts)

  • drafted == plain, token for token: 329/329 band, 100%; same-boot drafted==plain at 4.5k and 62k under the logit-dump instrument.
  • resumed == fresh: cache5 6/6 (the 21k canon identical across full/resumed/resumed-plain/after-two-conversations; the extension equals its fresh pass).
  • concurrent == solo: 4/4 lanes at gen 64 and 256-token shapes; 8 lanes 32/32; 12/12 under sampled draws with unequal lane lengths.
  • FP64: the per-operator reference suite at three sides (kernel, FP32 source contract, FP64 ideal); Metal-vs-FP32 4.10% NRMS at 2.9 bpw, argmax agreement held at every width.
  • Kernel contract: 89/89 family kernels, gates 1–4; 186/186 planted controls caught.
  • Determinism: the ten-second select probe (in-tree) guards the one-shot select's radix bookkeeping; same-boot five-repeat determinism 5/5 at 4.5k.

Hardware class and envelope

One Apple Silicon Mac, 256 GB unified memory (receipts on M5 Ultra; the kernels carry no claim about other chips). The EXL3 pack is resident (~197 GiB); the Engram tables stream from disk (~189 GB on disk). Fully-resident (~386 GiB) needs a 512 GB-class machine and --engram-table-dir. Context 65,536 (the model's window). Loader defaults to 70% of RAM; this pack opts past it with GLM_LOAD_LIMIT_GB (the Metal working-set ceiling clamps it).

Where this lane goes next (required development, not in this PR)

This lane is usable and exact as submitted, and it is not finished. Development continues on three axes, in the open — alongside the custom 4-bit-on-M5 Metal kernel work announced upstream, which this lane's EXL3-specific kernels complement rather than duplicate:

  • Longer context. Boot admission is nearly flat in the window — 131,072 and 524,288 both boot on a 256 GB machine (235.9/236.5 GB admitted; the compressed KV costs ~0.6 GB from 65k to 512k). 131,072 is receipted as of this draft (the serving battery green at the window, plus a 126,454-token single-turn session past the model's native window: coherent, mechanically accurate, drafted decode 37.7 tok/s). Next: 524,288 session receipts, and at much larger windows a denser KV format (the CUDA release line's nvfp4_ds_mla holds 2.33M tokens in 17 GiB) plus rope-scaling receipts for the attention/index layers beyond the native window.
  • Reduced memory footprint. The EXL3 2.9 bpw pack is the quality band's floor today — and it is a dial, not a wall: a 2.8 bpw pack already exists with oracle receipts (190 GB, ~7 GiB freed), and 2.5 bpw and below are unpriced quality questions the FP64 oracle machinery can measure directly (~170 GiB at 2.5). Pricing them is the first work item after this PR; the band decides, on numbers.
  • More speed. The priced levers left standing: the merged glue dispatch (6–10 µs/dispatch is the unit price), and the pack-format decision above — which is also the only measured path to ~1,400 prefill / 70 decode.
  • Prompt-cache parity. The lane keeps a conversation's state for its next turn (the 21,966-token resume, cached=21,888) but nothing for a re-sent identical prompt, so tools/bench_cache.py --require-reuse reports kept 0 at its 2,570-token fixture. The other families serve a repeated prompt from cache; matching them is the next cache item, not an exception request.

The reference points are the CUDA release lane's own receipts (2× DGX Spark, TP=2, FP8 checkpoint, NVFP4 KV): their 1M ceiling is per-request over a 17 GiB KV pool, not 1M resident tokens.

Known issues

  • Governor first-touch: the gather's latency probe warms on its own sample, so the first pass over novel text pays the cold-read path by construction (35k first touch 968 tok/s vs ~1,300 page-warm). This is the measured trade that removed a page-cache pollution stall, not a defect.
  • Shared rounds decode plain: a multi-lane round runs the exact plain window for every lane (lane opt-in shared_plain); drafting is a solo-lane property.
  • Cold concurrent prefill serializes: four simultaneous cold 22k prefills put up to ~73 s of first-token wait on the last lane.
  • Long-context decode falls with context: drafted holds 30+ tok/s to ~41k, reads ~25 at 62k.
  • 2.8 bpw packs: duplicated tensors are owned by the index; a shard contributes only the tensors the index maps to it.
  • The pack-format ceiling: the measured path to ~1,400 prefill / 70 decode is a 3-bit affine expert repack whose quality ratio (1.095) misses the 1.05 band — it ships only if the band is accepted.

Test plan

zig build native builds the lane. The family suites (test-deepseek-v41, test-deepseek-v41-reference, test-deepseek-v41-weights, test-dsv41-metal, test-deepseek-v41-index-probe) and the family-scoped kernel contract (tf-kernel-contract --only dsv41) run beside or without a serve; the serving receipts regenerate through a harness kept outside this diff (cache5, stand, margins, band).

The rework (2026-11-08): every review item closed

Following the review, the branch was reworked and re-gated, then re-blended onto current
main. The final head is bec140d54: 6d4744139 merges upstream/main 5cfbc8880,
and bec140d54 adds the lean-gate listing. The merge resolved four intersecting files by
keeping both sides — build.zig, tools/zig/lean_check.py, THIRD_PARTY_NOTICES.md and
zig/tests/contract/metal_limits.zig — with no main change dropped.

A build defect this update fixes. The earlier head acfb1d4ec did not compile three host
steps that pass on main: test-dsv41-draft, test-dsv41-cuda-host and
test-dsv41-device-host. Two causes, both this lane's: a draft-debug timer used
std.time.Instant, which Zig 0.17 does not have; and the lane had rewritten the shared
kv_state.zig into a Metal-specific layout, dropping Region and Encoding that main's
CUDA adapter and its tests still import. Fixed by restoring main's kv_state.zig
byte-for-byte, moving the lane's Metal cache state into a lane-owned metal_kv_state.zig,
and deleting the 17 draft-debug sites. cuda_transaction.zig and
slot_transactions_test.zig are untouched, kv_state_test.zig is back in the test graph,
and the battery now runs all three steps.

Copy rule:

  • The MLX header flatten and its gather kernels are deleted — they were unused by the
    lane (verified: zero consumers) and should never have been in the diff.
  • hc_boundary.metal started as a port of @BobClawblaw's work (the file's own first
    header admitted it) — it has been rewritten from the release's own inference code and
    proven behaviour-identical to the version it replaces. Bob's original work is credited
    here with thanks; the file in this branch is release-derived.
  • model.zig, attention.zig (including the drafter), the exl3_projection host glue
    and every kernel body that followed another engine's structure are re-expressed from
    the release's own inference code (inference/generate.py, inference/engram.py,
    release revision 314113f8), with the derivation cited in each file. The ported-from
    notes are gone because the ports are gone.
  • affine_mm.metal and gemm_nax.metal are byte-identical to upstream again — our
    router-gate GEMM variant moved into a lane-owned file, so the shared files carry no
    diff and no separate PR is needed for them.

Reviewability:

  • All round-history labels and internal measured figures are out of the source.
  • Zero files over 600 lines (the largest remaining diff file is exactly 600).
  • Core is family-neutral: this lane adds no TF_DSV41_* name to core/. The 61
    already there in core/profile.zig and core/profile_test.zig are upstream's and
    untouched by this diff; the 109 that were in exl3_projection.zig are gone from it,
    and the /tmp/r503-nomerge toggle is gone from backend.zig.
  • The dead weight is gone: the MLX flatten (6,267 lines), the bench scripts, the temp
    kernels and ids files. The whole diff is 243 files, +34,379/−1,458.
  • The lane's 210 existing tuning and diagnostic switches keep their own readers; they are
    listed under the lenient-switch section of docs/environment.md, the documented mechanism
    for switches that retain their existing policy. They are the levers the receipts above were
    measured with, and projection_knobs.zig joins the checker's name-reader list.

Gates on the final head — the full battery, run twice (builder and overseer):

  • zig fmt --check clean; metal-mirrors 86 structs; native build OK;
    test-deepseek-v41 green; test-exl3 green; dev-tools green;
    test-deepseek-v41-reference green; test-dsv41-draft, test-dsv41-cuda-host and
    test-dsv41-device-host green; zig build test 770/770 steps (9,189/9,268 tests cold,
    79 skipped; 342/355 warm, 13 skipped); tf-kernel-contract --only dsv41 89/89 kernels,
    186/186 controls, 0 limit problems.
  • Lean: 396 problems on this head against 398 on 5cfbc8880, 0 new.
  • Speed: every canon sha matches the standing receipts. Prefill on this head is
    1,270/1,271 tok/s @3k on -Dcpu=apple_m1, the target CONTRIBUTING prescribes;
    decode is inside the run-to-run spread (plain 44.3 @21k; drafted 54.9 @21k, 44.8 @4.5k,
    49.3 @62k). Full table in the branch's ~/rehearse/receipt-stand-*.
  • The prefill regression and its fix: the rework's first clean build exposed a
    latent plan default — the routed prompt projection defaulted to the 128-column tile
    instead of the receipted 64-column nax59 body, costing 867 ms of an 874 ms prefill
    delta. The last commit restores the 64-column default; TF_DSV41_EXL3_NAX_COLS=128
    still selects the other body. Host-side plan selection only: no kernel, argument,
    arithmetic or dispatch-order change; the canons hold.

Quality receipts — stated exactly:

  • Kernel-level exactness is green: the per-operator FP64 suite passes (projection
    classes ~1.6e-7 NRMS, worst column reported), and the kernel contract passes 89/89
    with 186/186 controls.
  • Whole-model quality: our own strict bar, not quite met — by a tiny fraction. We
    set ourselves an unusually strict internal standard: a whole-model KL line against an
    FP64 reference. On the receipted prompt set the engine shows 7 token disagreements
    against that reference (mean KL 1.058, max 5.766, every difference's margin filed in
    oracle-fp64-stream-margins.json). The bar is stricter than it has any right to be:
    even the reference chain's own FP32-vs-FP64 gap measures 2.90% NRMS, so a 1% KL line
    is below what the reference itself can satisfy. It was our own standard, not an
    upstream requirement. Quality has dropped by a tiny fraction relative to that line,
    and we'll keep working toward it — others may reasonably consider the current state
    already very good. What IS green: the per-operator FP64 suite (projection classes
    ~1.6e-7 NRMS) and the kernel contract (89/89 kernels, 186/186 controls).
  • KL against the release's own Python weights is not measured yet — that path needs
    CUDA/tilelang on our hardware, which is untested at this time. We expect it to work
    and will add the table when we can run it.
  • Cross-family receipts (Qwen3.8-27B-MLX-4bit, main vs branch): tokens identical on
    prose, code and math at 64 tokens each (sha16 380bee10dc173a6e, dcf2c9df4e7456fe,
    7e2aefed520685b2), branch speed 0.999/1.000/0.999 of main. Receipt:
    ~/rehearse/qwen-cross-family.json, in the branch receipts. The shared files this lane
    touches (prompt cache, lane loop, one GEMM kernel) serve every family.

What follows if useful: the same receipts as a single file with every command and
output, and the KL comparison against the release's Python weights on CUDA hardware.
Both can be appended as they complete.

@EugeneClaw

Copy link
Copy Markdown
Contributor Author

Frank here: This is an attempt at correctness with what I have - it is essentially the minimum viable version. It's not competitively fast, but it's totally usable. It allows a 256GB Mac to host a larger than RAM model with engram tables (with less than 15% loss). The main drawbacks are memory use (asks for 260GB but won't use more than 240GB) and context (can do 512k, but set to and tested to 131k) so I'll keep chunking away at those things. Am aware of upstream metal work and will respond accordingly (hoping for some free gains tbh).

@ashhart

ashhart commented Nov 6, 2026

Copy link
Copy Markdown
Owner

/atc exchange a2aaa49927442f4f95c986d3e4ed86f7e43a3c23a0ae4b394906358a924af9e2

@ashhart

ashhart commented Nov 6, 2026

Copy link
Copy Markdown
Owner

Thanks @EugeneClaw for the lane and for the receipts.

We can't take this PR in its current form, because parts of it break our copy rule.

  1. MLX's own headers. zig/kernels/metal/core/mlx/mlx_steel.h is MLX v0.32.4's Metal headers flattened verbatim, and gather.metal instantiates MLX's affine_gather_qmm_rhs from it. The native engine doesn't carry library source, even with a notice. Our kernels may match MLX's arithmetic, but they're our own code. These files, and the tests and tools built on them, need to come out.
  2. Ported structure from another engine. Comments in core/exl3_projection_nax.metal, core/exl3_projection.metal, core/exl3_projection.zig, families/deepseek_v41/model.zig and attention.zig say the kernels and forms are ported from mlx-serve (dsv41_exl3_mul1h_k3, dsv41_exl3_guone_k3, dsv41_prefill_dig_gemm_*, commitMain, dsparkAttention and others). We don't take another engine's code or structure, with or without attribution. Those parts would need to be written from the model's release code and our own kernels.
  3. Files copied from @BobClawblaw's fork. core/affine_mm.metal, core/affine_mm_i8.h and deepseek_v41/hc_boundary.metal are copied whole from @BobClawblaw's deepseek-v41-main branch. affine_mm.metal is a kernel every Metal family uses. A change to it needs to come as its own PR, with @BobClawblaw's agreement in the thread and compiled-output identity for every family that uses it.

Following DeepSeek's release inference code with the notice you added is fine.

Beyond the copy rule, a few things would make the lane reviewable:

  • Split it into PRs we can read, with every file under 600 lines. Today 20 files are over, the largest at 6,267.
  • Leave out bench/ (the round scripts and the two patch*.diff files), zig/tf_ids_tmp.zig, glue_tmp.metal, r519_bf16_mma.metal and the repo-wide *.log ignore.
  • Drop the history from the source: the "ROUND-…" labels and the measurements in comments.
  • Keep core family-neutral. Please remove the TF_DSV41_* switches and profilers from core (prompt_cache.zig, lanes/engine.zig, metal/command.zig), and don't add a DeepSeek shell check as a dependency of zig build native.
  • Show shared changes leave other families alone. For the shared changes that remain (the prompt-cache lookup in prompt_cache_index.zig, the lane loop, gemm_nax.metal, lean_check.py), please show that other families' tokens are unchanged, with their speed on main against the branch, on your M5 Ultra. The Qwen3.8-27B is the obvious one.
  • Paste the receipts. We don't hold a DeepSeek checkpoint, so your receipts are the evidence. For each exactness and speed claim, please paste the command and its output. For quality, please give top-1 agreement and KL against a reference of the release weights on the same prompts, with every top-1 difference and its margin.

Thanks for the detailed write-up of what was tried and priced out; it helps whoever works on this next.

The glm_dsa exes take the tokenizer module from the engines import table
(the file was compiled twice, once per module). The reference suite calls
encodeDiagnostic with prof and segs. The four prof clocks guard Windows,
where std.c.clock_gettime does not compile; those builds read 0 from an
instrument that never runs there.
bench/ (the round scripts and the two patch diffs), tf_ids_tmp and its
tf-ids target, and the r519 bf16 experiment with its fixture leave the
diff. The repo-wide *.log ignore line goes (upstream keeps its own).
glue_tmp.metal was load-bearing, so it is glue_rows.metal now, with the
records file, the contract tests and every comment following. No kernel
text changed.
Dead code under the copy rule: no lane file dispatches tf_gather_qmm_*; zig/tests/mlx_gather.zig and the tf-mlx-gather entry existed only to time the reference. Removes core/mlx/ (mlx_steel.h + gather.metal), the core_mlx_steel embed, the test and its build entry. Gates: fmt, native, test-deepseek-v41, dev-tools.
…switches

The diff adds comment blocks that carry experiment labels and measured numbers; this pass keeps the technical statements and drops the history. Core is family-neutral again: the round-trip profiler and its clock leave lanes/engine.zig, the resumed-tail switch moves to prompt_cache.Rules (set by the DSV4.1 lane host), and the family metal-mirror shell check hangs off test-deepseek-v41 instead of zig build native. Gates: zig fmt --check, native, test-deepseek-v41, dev-tools.
Every file in the branch is under 600 lines: lanes/engine (pipelined round), core/prompt_cache (marks), server/cli (scalar helpers), weights (source helpers, int8 sidecar), backend (policy, run), and the index Metal sources (scalar + tensor-unit). Moves are function-for-function, with in-struct aliases for the methods; no arithmetic moved. Gates: zig fmt --check, test-native, test-deepseek-v41, dev-tools.
…he release

R1's first half. TF_DSV41_Q_RMS, TF_DSV41_MTP_Q_RMS and TF_DSV41_Q_RMS_DEBUG and their ones/dump buffers are gone; the release has no per-head query RMS (it normalizes qr before wq_b, which is the shipped path). The provenance comments in model.zig/attention.zig now state the release's order (wq_a -> q_norm -> wq_b -> rope; wkv -> kv_norm -> rope; sparse attention; inverse rope; wo_a groups -> wo_b) and the DSpark block forms, with no sibling-engine citations. No default-path arithmetic moved: every removed arm defaulted off. Gates: zig fmt --check, native, test-deepseek-v41, dev-tools.
@EugeneClaw

Copy link
Copy Markdown
Contributor Author

Ash, thanks for the review and pointers which have been or will be sorted - some points of clarification:

The three Bob files (hello Bob, huge fan - biggup yourself) are not copies of his fork:

core/affine_mm.metal: ours is bb98749a (2026-10-06); Bob's is 03264c9c (2026-10-23). Different contents.
core/affine_mm_i8.h: ours is 0297269b; Bob's is 644911ef. Different contents.
deepseek_v41/hc_boundary.metal: ours is e4f3110d; Bob's is 40281b4c at core/hc_boundary.metal. Different path and contents.
Blob differences establish that they don't match, not that their derivation is independent. If any shares ancestry with Bob's work, we'll treat it as a port and rework it.

Agreed actions:

Move the affine_mm.metal (+146 lines) change into a separate PR, with compiled-output identity for every Metal family that builds it and Bob's agreement if it touches his work.
Remove mlx/.
Rewrite mlx-serve structures from the release's inference code.
Split the changes.
Remove ROUND labels from source; keep core family-neutral.
Provide cross-family receipts (Qwen3.8-27B token identity and speed on main vs. branch).
Provide command/output receipts for top-1 agreement and KL against release weights.
Working on it as we speak. Each piece will land as a separate commit.

Hopefully next time I don't bang my head, stub my toe and tread on a garden rake in a series of comedy collisions with the rules. There's a lot of reverse engineering of concepts thats been taking place to drive iterations rather than lifting code directly.

…ement

The top-1/KL receipt the review asks for needs a divergence term over the whole next-token
distribution, not only argmax and NRMS. `qualify.zig` already runs the independent CPU teacher
(FP32 or FP64 over the release pack's weights) against the GPU decode; this adds KL(teacher ||
engine) per row, its mean and worst, in f64 with a max-subtracted softmax. The fields are added
with defaults, so existing oracle JSON readers keep working. Gates: zig fmt --check,
test-deepseek-v41-reference.
…MM moves lane-side

core/affine_mm.metal and prefill/gemm_nax.metal are byte-identical to upstream's
(sha a91a3539 / 16a1d78a). The router gate's fp32-destination GEMM is the lane's
own copy under deepseek_v41/gate_gemm.metal, registered in the metal sources and
loaded by ops.zig; the gate bench reads the lane source and the contract records
keep their names.

The unused int8/weighted-GLU affine delta (only a flag-gated FP64 ground-truth
test) is removed with its header and test. It was dead in the lane and carried a
whole-file port attribution, so re-homing it would not clear the copy rule.

Gates: zig fmt --check 0; check_dsv41_metal_mirrors 86/86; zig build native 0;
test-deepseek-v41 0; dev-tools 0.
The drafter's conditioning, noise-token rows and Markov head chain cite the
release's Transformer.forward_spec / DSparkBlock.forward_embed / forward_head
(inference/model.py, revision 314113f8) instead of the sibling engine. The
remaining port language is gone. Comments only: no non-comment line changed.

Gates: zig fmt --check 0; zig build native 0; test-deepseek-v41 0; dev-tools 0.
…y leaves the comments

The EXL3 files no longer name another engine or a round. The funnel/dig forms,
the register-only decode, the pair and the multi-row dense body are described by
their arithmetic and their own shapes; the model-facing order cites the release
(inference/model.py, revision 314113f8). Comments only: no non-comment line
changed.

Gates: zig fmt --check 0; zig build native 0; test-deepseek-v41 0; dev-tools 0.
…ured numbers

The five files the earlier pass skipped (attention, model, the EXL3
projection Zig glue, and both EXL3 Metal files) now carry the same
comment rule as the rest of the diff: no ROUND-/R- labels, no handoff
or other-engine references, no measured figures. Comment text only;
git diff -U0 over the five shows no non-comment line changed, so the
kernels and the host glue are bit-identical by construction.
…duplicated code go

hc_boundary.metal's header no longer claims a whole-file port: it cites
Block.hc_mixes, hc_pre and hc_post in the release's inference/model.py at
revision 314113f8, and the duplicated mix-product and two-stage reduction
code is one pair of helpers. The arithmetic, the thread mapping and the
lagged collapse are unchanged, so the kernels stay bit-identical; the mHC
GPU tests pass.

The remaining comparator/MLX/Bob references in the diff are reworded to
describe the form by its arithmetic or removed. The other files here change
comments and bench output strings only; no engine path changes.
… walk

The prompt tile no longer carries an inline decode job: TfExl3DigJob holds the
resident pack slice, the k/n strides, the width/codebook and the live tile
extents, built once from the call args and the route. TfExl3DigWindows holds
the lane's eight-state cursor (slot, word offset, shift, word distance) and is
shared by the default tensor-unit prefill body, the grouped table body and the
scalar prefill twin (the named TfExl3ScalarWindow fields replace the packed map
word). The arithmetic, the k order and the MMA order are unchanged, so the
kernels are bit-identical by construction; the shared prefill and grouped-tile
GPU tests in test-deepseek-v41 pass, and the prefill spot rows report zero
parity mismatches.

The model-facing order cites the release (inference/model.py, revision
314113f8); the trellis arithmetic and the cursor are our own pack format.

Gates: zig fmt --check 0; zig build native 0; test-deepseek-v41 0;
dev-tools -Doptimize=Debug 0.
…nd row walk

The register-only routed decode and its paired form no longer rebuild the
pack slice and the lane cursor inline. TfExl3DecodeJob names the trellis,
k/n strides, width and codebook for one row (the dense call takes its own
trellis, the routed call the expert's descriptor); TfExl3RowWalk names the
lane's first word, bit offset, shift/mask and the next two words. The pair
body uses the same two structs, so the duplicated setup is one place.

The arithmetic, the k order, the product order and the reduction tree are
unchanged: the shared prefill, grouped-tile and resumable-window GPU tests
in test-deepseek-v41 pass, and the single-row/pair bodies are the same code
with named fields. The release is cited for the MTP/draft projection shapes
(inference/model.py, revision 314113f8); the pack layout and the row walk
are our own.

This is the register-only single-row and pair pass. The funnel, wide and
multi-row dense bodies still carry their inline setup; they are named as
remaining R3 work in the record.

Gates: zig fmt --check 0; zig build native 0; test-deepseek-v41 0;
dev-tools -Doptimize=Debug 0.
@ashhart

ashhart commented Nov 6, 2026

Copy link
Copy Markdown
Owner

Thanks @EugeneClaw. The MLX headers, the bench/ and tmp files, and the ROUND- labels are gone.
One thing to flag early: this push removes the mlx-serve notes from exl3_projection*.metal, exl3_projection.zig, model.zig and attention.zig, but the code they described is the same, line for line. Removing a note doesn't change where code came from, so these parts still need the rewrite from the release's inference code you mentioned. Your two commits since (the prompt tile and scalar prefill, then the register-only bodies) start that in exl3_projection.metal and exl3_projection_nax.metal, about 300 of their lines so far. Until the rewrite covers a file, please put its note back.
On hc_boundary.metal: its first line said "ported whole", and most of its code is unchanged from that version. Different blob hashes show the files differ now, not where they came from. Could you say in the thread where it came from? If it started from @BobClawblaw's work, it goes with the affine_mm PR.
Still open from our list:

  • the split, with 11 files still over 600 lines;
  • TF_DSV41_* in core/exl3_projection.zig and core/expert_groups.zig;
  • the /tmp/r503-nomerge toggle in backend.zig;
  • other families' numbers, and the receipts.

Thanks for working through it.

…walk

The tensor-unit decode's three remaining bodies no longer rebuild the pack slice and the lane
cursor inline. The multi-row routed decode, the 128-column dense decode and the column walk build
TfExl3DecodeJob (the expert descriptor or the call's own trellis, the k/n strides, width and
codebook); the eight-state cursor is the shared TfExl3DigWindows walk, now with the tile's column
stride and the ring's next words, so the funnel, two-word and general paths take rel/off/sh/at
from it. The single-row and pair register bodies keep TfExl3RowWalk.

The arithmetic, k order, MMA order, product order and reduction tree are unchanged: the walk is
the same map arithmetic with named fields, and the cursor was checked for halves 4, 6, 8 and 16 on
every lane. The file compiles standalone with the default, funnel, two-word, compact and table
defines. The body comments also lose the last task label, the probe measurement and the orphan
possessives the earlier attribution strip left behind.

Gates on the tree: zig fmt --check 0; metal compile 0 (default plus the named defines);
zig build native 0; test-deepseek-v41 0 (the csa2 diagnostic and failed-command line are the same
on the base 1530351); dev-tools 0; test-exl3 0.
The copy-rule comment pass left orphan possessives ("are 's"), a task label and a
measurement in the rewrite-set files. This restores the sentences: the EXL3 glue
describes the single-tile body, the backend notes drop their round numbers and the
one measurement, and the docs drop the round cross-references. Comment and prose
only; no non-comment line changed in the Zig sources.
…h order is kept and proved byte-identical

The file started from BobClawblaw/TensorFold deepseek-v41-main 72a552e. It is now written from the release's Block.hc_mixes, hc_split_sinkhorn_kernel, hc_pre and hc_post (inference/model.py and inference/kernel.py at 314113f8): the mix projection over the flattened stream, the Sinkhorn split, the lagged collapse, the norm and the post expansion.

The two launches and the reduction order are unchanged by design. The split cannot read the 24 mix totals until the first launch finishes and Metal has no grid-wide barrier; the one-threadgroup fold was built and measured 2.4x slower at the decode's row counts. The reduction order is the canon's.

A byte-for-byte A/B against the previous kernel over rows 1, 2, 5 and 17 and every expand/mixes combination (16 cases) is identical in every output buffer, so no reply can move. The harness and its log are in the plan record, scratch/hc-ab-20261106/. Metal compile and the fast gates pass.
The attention encode is now the release's decomposition (inference/model.py,
revision 314113f8): the query's low-rank pair and its norm, _window_kv's norm,
rope and fp8 pack, _compress_kv/_compress_topk_idxs' compressor and pick,
sparse_attn over the two sources, and the grouped wo_a/wo_b close. The stage
context carries the views the stages hand forward, so each stage reads as the
release's step instead of one inline block.

The dispatch order, the fused forms' arithmetic, the k order, the split counts
and every A/B arm are unchanged: the bodies moved, they were not retyped. The
trellis arithmetic, the ring layout and the scan remain this lane's pack
format.

Gates: fmt 0; metal-mirrors 86/86; native 0; test-deepseek-v41 0; test-exl3 0;
dev-tools 0; umbrella test 0 (COMBINE GATE 45056/0); reference 0.
…stages

The drafter's block attention is now the release's DSparkAttention.forward
(inference/model.py, revision 314113f8): the draft query's low-rank pair and
norm, the draft key's norm and rope, the packed window, the scan over the main
model's window rows, and the grouped wo_a/wo_b close, with the draft rows'
publish last. The stage context carries the encoder and the views the stages
hand forward, so each stage reads as the release's step.

The dispatch order, every rope/scale/mask arm, the debug dumps and the prof
splits are unchanged; the bodies moved, they were not retyped. The pack
arithmetic, the ring layout and the scan remain this lane's pack format.

Gates: fmt 0; metal-mirrors 86/86; native 0; test-deepseek-v41 0; test-exl3 0;
dev-tools 0; umbrella test 0; reference 0.
The registry conflict keeps upstream's registry_joins.metal_before and the lane's dsv41_records.metal; all other overlaps auto-merged.
metal_targets hands the PDF worker test to build/server.zig (596); engine_api shares the Engram fields' comment (600).
…mbers

45 Metal sources (27 new, 17 evolved, 1 split); prefill 931/899, decode 44.4/43.2, drafted 45.0/54.5, suffix 0.85 s. engine_api keeps the Engram stream's row-by-row wording.
…plit count is 26/17/2

The exact 22:09 and 22:40 receipts differ by ~2% in the cold-prefill and decode rates; the block now reads at the stable rounded level. 45 Metal sources = 26 added, 17 modified, 2 renamed.
Two adjacent field pairs share one comment (logprobs/prompt_logprobs, cfg_enabled/cfg_batched), 600 to 598. Upstream main 78bdb adds two lines to this file; the merged tree stays at 600.
@EugeneClaw
EugeneClaw force-pushed the contribution/dsv41-exl3 branch from 38834da to 2972ae7 Compare November 7, 2026 23:09
@EugeneClaw

Copy link
Copy Markdown
Contributor Author

Ash — you asked where hc_boundary.metal came from, the answer is in the
file's own history: its first version carried the header "ported whole from
BobClawblaw/TensorFold, branch deepseek-v41-main, commit 72a552e (Apache-2.0)" —
it begun with a port of Bob's work for speed and technique comparisons that we could prioritse.
The details got confused and so we didn't even know until rechecking thoroughly. Oops. Confident now.

Per the commitment in our earlier comment, we treated it as a port and rewrote it: the
current hc_boundary.metal is written from the release's own inference code (the
hc_mixes / hc_pre / hc_post boundary and hc_split_sinkhorn_kernel, release
revision 314113f8), and it is proven byte-identical in behaviour to the version it
replaces — same canon receipts, same contract controls. Bob's original work seeded our
understanding of the boundary and we're grateful for it; the file in this branch is
release-derived, not his (although we are standing on his shoulders, knowledge-wise)

So the disposition is: rewritten from the release, no separate PR needed, and Bob's
contribution acknowledged here and in our record. If you'd rather see the port version
and the rewrite side by side, both are in the branch history — say the word and we'll
point at the exact commits.

…and takes main's fallible env parser

# Conflicts:
#	zig/src/families/deepseek_v41/draft/lanes.zig
@EugeneClaw

Copy link
Copy Markdown
Contributor Author

Re-blended onto main 5874551fa in f2e28e9ca (one conflict in draft/lanes.zig) and added acfb1d4ec: the routed prompt tile's 64-column default. Prefill 931 -> 1,270 tok/s; the body carries the detail.

The Metal-specific kv_state rewrite dropped Region/Encoding and the
begin/keep/rollback transaction API, so the unchanged CUDA adapter and the
draft, cuda-host and device-host steps stopped compiling. Restore main's
generic kv_state.zig (with its kv_state_test import), move the Metal cache
ownership and Plan/State into metal_kv_state.zig, and point the Metal
consumers at it.

Remove all 17 TF_DSV41_DRAFT_DEBUG sites, including the std.time.Instant
timer in draft/lanes.zig. Keep the served shared_plain opt-in in the two
served configs rather than the generic lane model, so the CPU twin host
tests exercise the generic config while the Metal lane serves unchanged.
Resolve four shared files by keeping both sides:
- build.zig: main's metal_prompt_sources and host_ci steps plus the lane's
  tokenizer module for the three host builds.
- tools/zig/lean_check.py: main's direct-getenv rules plus the lane's
  nfkd_data generated-output exemption.
- THIRD_PARTY_NOTICES.md: main's Clef CUDA heads plus the lane's EXL3
  split and DeepSeek-V4.1 attribution.
- zig/tests/contract/metal_limits.zig: main's LFM3 source routes plus the
  lane's dsv41 gate gemm route.
The merged lean rule counts a direct getenv of a TF_*/TENSORFOLD_* setting.
The lane's 210 existing experimental and diagnostic switches keep their own
readers, so docs/environment.md lists them as lenient; the lane's
projection-knob parser reads the names it is given, so it joins the checker's
name-reader list. No behavior changes.
@EugeneClaw

Copy link
Copy Markdown
Contributor Author

Fixed a build problem found while testing this lane. Three host steps that pass on main did not compile here: a draft-debug timer used std.time.Instant, which Zig 0.17 does not have, and the lane had rewritten the shared kv_state.zig, dropping Region and Encoding that main's CUDA adapter and its tests still import.

kv_state.zig is now main's byte-for-byte; the lane's Metal cache state moved to a lane-owned metal_kv_state.zig; the 17 debug sites are gone. test-dsv41-draft, test-dsv41-cuda-host and test-dsv41-device-host now pass, and the battery runs them.

Re-reading docs/pr/SKILL.md while doing that also showed a few things we had missed: the receipts now come from the repo's own tools/bench_*.py so they can be rerun, and the template lines we had left out are filled in. A couple of items remain.

Speed is unchanged: prefill 1,270/1,271 tok/s at 3,002 tokens, every canon sha identical. Head bec140d54, merged current main 5cfbc8880.

@ashhart

ashhart commented Nov 9, 2026

Copy link
Copy Markdown
Owner

@EugeneClaw thanks for the rework: the MLX headers, ROUND labels, /tmp toggle and core TF_DSV41_* reads are gone, and no file is over 600 lines.

  1. Copy rule. The kernel and drafter bodies first noted as following mlx-serve are still mostly the same code, line for line (for example exl3_projection_nax_fma*.metal, _wide.metal, and the drafter attention and commit code now in model_*.zig), and the new "Provenance: release" headers describe code that wasn't rewritten. Code derived from another engine can't land here, with or without a note, so those bodies need to be written from the release's model definition, or left out. Please also say which release revision you followed: the headers cite 314113f8, the notice 2cba9e42.
  2. Shared code. serve_engine.zig and serve_lanes.zig turn shared_plain on for the CUDA line, which changes its shared rounds; please drop that. The PR also replaces main's backend-neutral deepseek_v41/model.zig and load.zig, including three admission tests; please put the Metal lane in its own files. The dispatch counters in metal/command.zig run on every Metal family's dispatches, so please keep them in the family.
  3. Exactness. row_invariance.zig prints differences but never asserts them; please make it a failing test for the default prompt and decode bodies at windows 1-8, and declare that prompt rows differ across the ≤8 / 9-63 / ≥64 kernel classes. Please also point to file:line for the contract records behind "89/89 kernels, 186/186 controls"; we couldn't find records for the tf_exl3_* Metal kernels.
  4. Switches. Please read settings through platform/environment.zig, with blank reading as unset, and register policy switches in core/profile.zig, rather than 286 direct getenv calls.
  5. Evidence. Please paste the Qwen3.8-27B main-vs-branch commands and output with absolute numbers, top-1/KL against the release weights when you have it, and reconcile the description's "tiny fraction" with the docs' "band fails (1.647×)".
  6. Rebase and paperwork. Main has moved under build.zig, native/metal.zig, core/prompt_cache.zig, kernels/metal/sources.zig and contract/registry.zig, so a rebase is needed; please drop the comment change in qwen4/rotary_table.zig and split the PR as agreed. The CHANGELOG line is ours.

@EugeneClaw
EugeneClaw marked this pull request as draft November 9, 2026 12:30

This branch has not been deployed

No deployments
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