Skip to content

Qwen3.6-35B-A3B on Metal: routed experts in the 27B's engine (slice 1 of #931) - #1009

Draft
cshintov wants to merge 14 commits into
ashhart:mainfrom
cshintov:feature/qwen36-moe-metal
Draft

cshintov wants to merge 14 commits into
ashhart:mainfrom
cshintov:feature/qwen36-moe-metal

Conversation

@cshintov

@cshintov cshintov commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

qwen3_5_moe checkpoints (Qwen3.6-35B-A3B) serve text on the native Metal engine through the 27B's engine,
whose layers they share: each layer's MLP is now a tagged union of the 27B's dense MLP or routed experts. Slice 1
of #931. Images (slice 2) and MTP drafts (slice 3) are refused until their slices land.

  • Routing is Qwen3.6-35B-A3B on CUDA: routed experts and its MTP layer drafting chains (on #836) #856's rule: fp32 router logits, top-k by logit with ties to the lower id, weights
    exp(l - l_top) over the k rounded to bf16, the shared expert as one more slot weighted
    bf16(sigmoid(bf16(gate))), the slots' weighted sum rounded once to bf16. The logits run on
    core/moe_route's kernel; selection, the by-expert sort and the weighted sum are three new kernels in
    kernels/metal/qwen27/moe.metal; the experts run on core/tile_qmm's prompt tiles and decode twin.
  • The places that pinned the 27B's shape read the config: attention's head counts (the kernels take
    Q27_GROUP/Q27_KV_HEADS defines whose defaults are the 27B's 6 and 4, so the 27B compiles without them),
    the K/V unpack width, the cache size check and the shape check.
  • core/moe_route.metal: a logits lane stops at the row's end. Each lane took four 32-wide steps, which assumed
    GLM's 4,096; at 2,048 the last lanes read past the router's rows. At 4,096 the guard is never taken.
  • Prompt cache and --parallel lanes come with the 27B's host, so they are qualified here (their receipts
    below), and Qwen3.6-35B-A3B (qwen3_5_moe) on the native Metal engine: proposal and gate #931's slice 3 folds into this one.
  • Widths, on Metal only: MLX affine 4-bit experts in groups of 64 with 8-bit routers; other expert widths are
    refused at load (8-bit experts are slice 4). --drafter, --vision and image parts are refused.
  • qwen27's saved-state identity hashes its kernel sources, so the 27B's saved conversation states from an older
    build are not reused after this change.
  • The 35B's config, expert loader and adapter are the Qwen3.5 family's (families/qwen3_5/config.zig,
    metal_experts.zig, metal_native_moe.zig): qwen27 reads config.json through the family reader for the dense
    27B and the 35B alike, and the experts load in the 27B's one layer loop. The reader no longer crashes on zero
    heads or mrope_section values near the 64-bit limit; it returns its ordinary errors.

Design: one engine, and the alternatives

The 35B is the 27B's architecture (Gated DeltaNet layers, gated full attention every fourth layer) at smaller
sizes with routed experts in place of the dense MLP, so it runs the 27B's engine with a different MLP step. The
union states in each layer's weights which MLP it runs; the dense arm is the 27B's code as it was.

Rejected:

  • A separate qwen3_5_moe family like lfm2_moe: a second copy of the DeltaNet layers, attention, rounds and
    host (about 3,000 lines), or a cross-family import of the 27B's loop.
  • Running the 35B on the Qwen3.5 family's dense 2B Metal engine: it has no prompt cache, drafter or image path,
    so slices 2 and 3 would be rebuilt there. The config, expert loader and adapter still belong in that family.
  • A trimmed copy of the 27B's round code: no change to the 27B, but every later fix made twice.
  • The round code generic over the model (Runner(comptime M)): one copy, but a type parameter through about
    3,000 lines for a difference in the MLP step and a few pinned numbers.
  • An optional MLP hook in the mixer table: close, but a hook hides which MLP a layer runs.

The family's slices

  1. Text, with lanes and the prompt cache (this PR).
  2. Images on the checkpoint's own tower, which has the 27B's shape (27 blocks, 1,152 wide, 16 heads, patch 16,
    merge 2, no deepstack) apart from its last projection (2,048 wide against 5,120), on the 27B's image path.
  3. MTP drafts from the checkpoint's own head, with Qwen3.6-35B-A3B on CUDA: routed experts and its MTP layer drafting chains (on #836) #856's contract.
  4. 8-bit experts.
  5. (Optional) the z-lab DFlash drafter, with its own direct and full-token checks.

Receipt

  • Environment: this branch on main 6ee828c, before the rebase onto d1018b3 (what the rebase changed is in
    the item 2 comment: the long prompt's logits move by bf16 steps from main's 128-key decode chunks); Mac mini M4
    Pro (20-core GPU), 64 GB; macOS 26.5; Zig 0.17.0;
    mlx-community/Qwen3.6-35B-A3B-4bit at 38740b84; references from mlx_lm 0.31.3 (MLX 0.32.2) on the same Mac.
    The 27B checks use TensorFold/Qwen3.8-27B-MLX-4bit at 22d8d538 and z-lab/Qwen3.8-27B-DFlash2 at 50307d4c.

  • Exactness, 35B:

    • Greedy against mlx_lm, a 24-token chat prompt: all 60 tokens equal to <|im_end|>. Teacher-forced, native
      top-1 equals mlx_lm's token at all 60 positions.
    • A 378-token prompt (three 128-row chunks), teacher-forced over mlx_lm's 72 tokens: native top-1 differs at
      positions 24, 39, 44 and 54. At 24 and 39 both engines tie exactly (24: 20.0 and 20.0 native, 20.125 and
      20.125 mlx_lm; 39: 21.75 and 21.75 in both); at 44 and 54 the two engines' top-2 logits are each one bf16 step
      (0.125) apart, in opposite order. Free-running greedy diverges at the tie at 24.
    • Prompt and decode state byte-equal between prompt chunks of 128 and 16 rows, every 32-row seam.
    • Served, tools/bench_concurrent.py --alone --serial, levels 1 and 8, temperatures 0 and 0.7, code and chat
      prompts, 128 tokens: 72 of 72 replies equal their solo run, 36 of 36 equal serial (an earlier run at levels 1
      and 4, greedy: 30 of 30 and 4 of 4).
    • Prompt cache, a two-turn chat (a 6,588-token first turn, then that turn, its reply and a new question): turn 2
      resumes at 6,581 of 6,660 tokens, first token in 0.28 s against 15.9 s with --prompt-cache-gib 0, and its
      token SHA equals the cache-off pass; a second turn 2 resumes the same and is equal too.
      tools/bench_cache.py --require-reuse on an identical raw 8,192-token completion reports no reuse, on the 27B
      from this branch as on the 35B: the host keeps that prompt's state at its end, where an identical prompt
      cannot resume. The repeated and 4 concurrent replies equal the first.
    • Cancels, served (--parallel default, prompt cache on), five short sequences against a --prompt-cache-gib 0
      server's replies: a cancel in prefill then another request then the cancelled one whole; two streams in shared
      rounds, one cancelled in decode and its slot taken by a third; a cancel in prefill of a prompt resuming part of
      a kept state; an image refusal, then a text reply cancelled in decode, then that reply whole; four streams with
      two cancelled in decode, then those two again. All 13 completed replies (greedy and seeded sampling, some
      resuming kept states) equal the cache-off server's. Not run: a failed round mid-command-buffer (no fault hook).
    • tf-qwen27-streams on the 35B: 8 streams equal in a one-slot and an 8-slot runner over 48 tokens; 20 shared
      rounds, 376 logits rows equal the streams alone (windows of 1 to 5 rows).
    • The expert stage, moe_test.zig: every sequence of up to three stage calls (prompt tiles, the decode twin and
      the by-expert window at 1 to 16 rows, 258 sequences) gives each row its one-row call's bytes. It fails with a
      stale sort position read in decode.
    • Refusals: --drafter and --vision refused before load (unit-tested in native/metal.zig); served, a chat
      request with an image part gets HTTP 400 ("this server accepts text parts only; image, audio and video inputs
      are unsupported") and a text request on the same server then replies. tensorfold-native info reads the
      checkpoint as qwen3_5_moe, 4-bit in groups of 64, 19.03 GiB of weights.
  • The 27B's bits: tf-qwen27-run from main and from this branch on the 27B, a 378-token prompt, 64 tokens: the
    same tokens, the same state-and-logits fingerprint after the prompt, every 32-row seam byte-equal between chunks
    of 128 and 16 on both builds, teacher-forced top-2 ids and bf16 logits equal at all 64 steps.
    tf-qwen27-streams, tf-qwen27-dflash-run --compare-plain, tf-qwen27-lanes-run and tf-qwen27-reuse pass
    on both builds.

  • GLM: the logits guard is not taken at 4,096 (lane 31's last step is 127 of 128). tf_route_logits compiled from
    main's source and from this branch's, at GLM's router (288 outputs) and indexer gate (128, bf16), on the same
    random inputs at 1, 3, 16, 2,047 and 16,384 rows: byte-equal output (a planted change to the source differs at
    every height). No GLM checkpoint fits this Mac, so GLM's model-level bits were not rerun.

  • FP64 references (moe_test.zig, a 256-wide, 8-expert stage against the host's rules and float64): router
    logits within 9.9e-8 of 1+|f64| at worst; the stage's output worst column 1.49e-2 at scale 3.08 (16 rows), the
    prompt tiles and the decode twin equal on the same rows.

  • Kernel contract, zig build kernel-contract -Dcpu=apple_m1: SUMMARY 36 kernels: 36 pass gates 1-4, 0 fail, 2 known findings; 8 controls: 8 red as planted, 0 missed; 0 limit problems (the two known findings are
    affine_mm's, on main). Without the logits guard the 35B router fails gates 1, 2 and 4 and GLM's two shapes
    pass; without select's NaN rule select fails gate 1. New registrations:
    q27_moe_select, q27_moe_sort, q27_moe_combine at the 35B's shapes (rows 1, 16, 128), and
    tf_route_logits at GLM's router (4096x288) and indexer gate (4096x128, bf16 out) at 1 to 16,384 rows and the
    35B's router (2048x260) at 1 to 128 rows, each with its gate-5 launch record. The logits kernel now reserves
    its 256 threads in the source (max_total_threads_per_threadgroup(256)), as the vision kernels do: Xcode's
    translator gave no M1/M2 limit for it either way, and every caller launches 256. Gate 4:
    combine's stress inputs make fp16 and bf16 sums miss 256x. Select's gate 4 compares the GPU's weights with a
    float64 bound, but its inputs cannot show the contract's 100x separation: fp16 sums would miss 0x and bf16
    0.3x, because each of the eight exponentials in (0, 1] is rounded to a bf16 weight. Its operation allowance
    (2^16 + 64) stands for that bf16 rounding of weights stored as fp32, not for a count of operations. A bf16
    denominator can still move a weight by a step (an exponential of 1 beside seven of 0.001); fp32 is the rule.

  • Attention (attention_mpp, attention_prompt, attention_io), registered at both head shapes (the 27B's
    24 query heads over 4, the 35B's 16 over 2). A round's rows go through the engine's own plan and
    Attention.encode against float64 gated attention; prompt rows through the prompt kernel on tensor units;
    packing, append and keep bit for bit (keep also a second time on the caches the first left). Gate 4's inputs
    make fp16 score sums miss without limit and bf16 by 87,066x; they separate the score sums only. A plan check
    shows the decode shapes plan every prefix launch (1 to 4 tiles for chains, 2 to 4 for trees) and a tree over
    two tail chunks; it reads the engine's plans, not an M5's launches. Controls: a row seeing one key past its
    own, a merge dropping later tail chunks, an append one row late (all red here), and the prompt kernel losing
    its bounded last step (tensor units only). Gate 5 has 16 launch records per head shape; M1/M2 limits for the
    non-tensor entries come from the offline compile.

  • Not run here: the attention kernels' M5 paths (the prompt kernel, the prefix kernels, the lost-edge control).
    Predicted from the source, not observed: on an M5, gate 1 on prompt rows may give NaN where float64 gives +Inf,
    because the prompt kernel multiplies a masked future key's -Inf value by its probability of 0. Gate 1 plants no
    nonfinite input in the fused prefix. The commands for an M5, after zig build tf-kernel-contract -Dcpu=apple_m1:
    zig-out/bin/tf-kernel-contract --only 'metal qwen27 attention 27B prompt rows', the same for
    35B-A3B prompt rows and both decode rows, then zig build kernel-contract -Dcpu=apple_m1.

  • Decode speed, tools/bench_openai.py, 256 tokens, greedy, median of three: native 67.4 tok/s, mlx_lm.server
    85.6 tok/s on the same Mac. Four streams: 130-150 tok/s together.

  • Prompt speed, cold, first token on the same token arrays: native through tools/bench_cache.py --require-cold
    with --prompt-cache-gib 0, mlx_lm in-process (stream_generate, a fresh cache each; its server takes no
    token-id prompts):

    prompt native mlx_lm 0.31.3 native / mlx_lm
    2,048 4.08 s 2.32 s 1.8x
    8,192 20.7 s 9.21 s 2.2x
    32,768 148.8 s 49.3 s 3.0x
    65,536 481.2 s 135.2 s 3.6x

    The 35B has no earlier native release to compare with; 0.6.6's Mac figures are from an M3 Ultra.

  • Memory: the native server with 8 streams of 70,000 tokens, through the 8k cache and 8-stream runs, peaked at
    32.9 GiB (/usr/bin/time -l's peak footprint); mlx_lm peaked at 25.0 GiB of MLX memory at the 64k prompt.

  • Tests: zig build test test-kernel-contract test-golden fuzz-cortheon -Dcpu=apple_m1 passes (with the
    user-facing server stopped: core/paced_read's test needs 8 GiB free); plain zig build; zig build test-native; zig build test-qwen27; git diff --check; tools/zig/lean_new.py against the merge base: 517
    problems, 517 on the base, 0 new.

  • Not run: M1, M2, M3 and M5 chips and CUDA (one M4 Pro here); the 2B's checks (no 2B checkpoint here).

Open

  • Speed: native decode is 21% under mlx_lm's on this Mac, and a cold prompt takes 1.8x mlx_lm's time at 2k and
    3.6x at 64k. No speed gate for the 35B is agreed on Qwen3.6-35B-A3B (qwen3_5_moe) on the native Metal engine: proposal and gate #931; this PR is a draft until one is.
  • The attention contract's M5 run (above), and the M5/Ultra window from Qwen3.6-35B-A3B (qwen3_5_moe) on the native Metal engine: proposal and gate #931.
  • On a non-M5 Mac, main's full kernel contract stops at a Gemma 4 entry that needs tensor units; after the rebase
    the qwen27 and moe_route entries were run with --only. Both report 20 limit problems: 12 for Flash Next and
    eight for prefix_5/_6. The earlier 0 limit problems receipt above is pre-rebase, not a pass at the current tip.
  • prefix_5 and _6 (main's launches at 320 and 384 threads) are registered as pending: their M1/M2 limit needs a
    reservation in the source or a rule for tensor-unit-only launches.

Checklist

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off.
  • Drafted output equals "draft": false, a resumed prompt equals a fresh one, and concurrent equals solo.
    (The 35B drafts nothing yet; the 27B's drafted checks pass on both builds.)
  • No precision traded for speed. The 27B's bits are unchanged (old and new builds above); GLM's router kernel
    gives main's bytes at GLM's shapes, and GLM's model-level bits were not rerun.
  • A new or changed GPU kernel is registered with the kernel contract: the new ones, tf_route_logits and the
    attention kernels; the attention kernels' M5 paths are not run here (above).
  • Prompt processing is no slower than the last release: no earlier native release of the 35B; see Open.
  • The description says which platforms I ran, Metal M1 to M5 and CUDA, and which I could not.
  • New tests fail before the change, pass after it, and skip cleanly without their dependency.
  • Comments and docstrings are one line. No measurements or history in the source.
  • No personal data, machine names, internal hosts or local paths. No AI attribution lines.

@ashhart

ashhart commented Oct 17, 2026

Copy link
Copy Markdown
Owner

Thank you @cshintov! Routed experts on the 27B's engine, with shared lanes and the prompt cache kept, are a big step toward #931. Please keep this open while four things get done:

  1. Draft with the model's own MTP in shared lanes, and add its image tower on the native image path (keep the clear refusals until then). Then drafted == plain, resumed == fresh and concurrent == solo at every depth.
  2. Move the config, loader and adapter to families/qwen3_5, sharing helpers with the dense 27B rather than adding a second loop.
  3. Register attention_mpp, attention_prompt and attention_io for both head shapes, with bounds, references, failure controls and compiler limits.
  4. Add staged per-layer references and the shared-checkpoint CUDA comparison, and explain the long-prompt mismatch against MLX.

Posted by Ash's AI Assistant Synthena

@cshintov

Copy link
Copy Markdown
Contributor Author

Thanks Ash. Keeping it open.

Item 3 is in 157055b7: attention_mpp, attention_prompt and attention_io are registered at both head shapes (24 query heads over 4, and 16 over 2). The test drives the engine's own plan and Attention.encode against float64 gated attention, checks packing, append and keep bit for bit, and plants controls and gate-5 records (details in the description).

It ran on an M4 Pro only, so the M5 paths (the prompt kernel, the prefix kernels and the lost-edge control) are not run. Reading the source, I expect gate 1 on prompt rows to go red on an M5: the prompt kernel multiplies a masked future key's -Inf value by its probability of 0, which gives NaN where float64 gives +Inf. That is a prediction, not an observation, so there is no known-finding entry for it. Gate 1 also plants no NaN or Inf in the fused prefix. Could you run these on an M5?

zig build tf-kernel-contract -Dcpu=apple_m1
zig-out/bin/tf-kernel-contract --only 'metal qwen27 attention 27B prompt rows'
zig-out/bin/tf-kernel-contract --only 'metal qwen27 attention 35B-A3B prompt rows'
zig-out/bin/tf-kernel-contract --only 'metal qwen27 attention 27B decode rows'
zig-out/bin/tf-kernel-contract --only 'metal qwen27 attention 35B-A3B decode rows'
zig build kernel-contract -Dcpu=apple_m1

Next: 2 (move to families/qwen3_5), 4 (per-layer references, the CUDA comparison, the MLX mismatch write-up), then 1 (MTP drafts, then the image tower). On the long-prompt MLX mismatch: all four positions where the two outputs differ are exact ties or one bf16 step apart. I'll post receipts per item.

@cshintov

Copy link
Copy Markdown
Contributor Author

Item 4, first part: the shared-checkpoint CUDA comparison, logits only.

Setup: mlx-community/Qwen3.6-35B-A3B-4bit at 38740b84, the same four shards on both machines (sha256 equal). CUDA is
tf-qwen35-cuda from main at 76aafcf on a GB10 (sm_121); Metal is this branch at 157055b on an M4 Pro. Both are
teacher forced on the prompt plus mlx_lm's own reply, and I compare top-1 at every reply position.

prompt positions Metal = CUDA Metal = mlx_lm CUDA = mlx_lm
long (449 ids) 72 71 68 69
short (83 ids) 60 60 60 60

The long prompt's four mlx_lm mismatches (24, 39, 44, 54) are the four positions with the smallest Metal margin
between top-1 and top-2: 0, 0, 0.125 and 0.125, an exact tie or one bf16 step. At 24, 39 and 44 CUDA picks Metal's
token. At 54 CUDA holds the same two tokens one step apart in the other order (310 at 25.75, 685 at 25.625) and so
agrees with mlx_lm. That looks like rounding on near ties; the per-layer float64 reference will confirm it. Metal's top-1 logit is within one bf16 step
of CUDA's at the median, and the largest gap is 1.0 (long, position 71, same token).

CUDA's step and window modes gave bit-identical rows on both prompts.

Still open in item 4: the staged per-layer references. No engine dumps every layer today, so that needs new code on
both sides. I'd like to rebase onto main and do item 2 (the move to families/qwen3_5) first, so the dumps land in the
shared family code instead of in qwen27.

@cshintov
cshintov force-pushed the feature/qwen36-moe-metal branch from 157055b to 69c412d Compare October 21, 2026 11:28
@cshintov

Copy link
Copy Markdown
Contributor Author

Item 2: the branch is rebased onto main (d1018b3), and the 35B's config, loader and adapter now sit in
families/qwen3_5. This was a force-push; the old head was 157055b.

What moved

  • Config: qwen27 reads the model's geometry through the family reader (families/qwen3_5/config.zig, the one the
    CUDA engine uses), for the dense 27B and the 35B-A3B alike, and keeps only the Metal engine's own refusals. Shared
    files reach the qwen27 module through core, since Zig refuses imports outside a module's root.
  • Loader: the expert packing is families/qwen3_5/metal_experts.zig. It still runs inside the 27B's one layer loop
    (a layer's MLP is dense or experts), so there is no second loop.
  • Adapter: families/qwen3_5/metal_native_moe.zig, the Metal twin of cuda_native_moe.zig. native/metal.zig
    reads it instead of comparing model-type strings, and opens the 35B through the dense 27B's path.

Checks

  • The 27B, 35B-A3B and 27B-with-MTP configs parse to the same values as before. I also ran 1,580 single-field edits
    of those configs through the old and new parser: 219 differ, all on malformed values (the table is in the config
    commit's message).
  • The family reader crashed on three malformed configs that the Metal engine now reaches: zero KV or key heads, zero
    attention heads without head_dim (both divide by zero), and mrope_section values near the 64-bit limit
    (overflow). Each now returns the reader's ordinary error. The tests enumerate the field combinations and crash
    with the fix taken out.
  • The 35B's teacher-forced top-2 is identical before and after the move, on both prompts. The CI set, test-qwen27,
    test-native, test-qwen35 and every qwen27/qwen35 host step pass; lean check adds nothing. zig build test fails
    one test on this Mac, core.paced_read, and main fails it the same way here.

What the rebase changed

  • On the long prompt, Metal's logits move by bf16 steps. The cause is main's 128-key decode chunks below 8,192 keys:
    with fine_until set to 0 the output matches the old branch at all 72 positions. Against item 4's runs, Metal's
    top-1 token now matches CUDA's at 70 of 72 positions and mlx_lm's at 69 of 72. The short prompt is unchanged.
  • Main's frame grows to 512 prompt rows on tensor units. The expert stage now takes the frame's rows (it kept 128,
    which would have failed a 35B prompt chunk past 128 rows on an M5), and the expert and router contract entries
    check 512 and 2,048 rows.
  • Main launches the prefix kernel at 5 and 6 tiles once a cache has 64 chunks. The attention contract now plans and
    registers prefix_5 and _6 for both head counts, and the launcher and the contract read one rule for the tile count.
  • On a non-M5 Mac, main's full kernel contract stops at a Gemma 4 entry that needs tensor units, so I ran the qwen27
    and moe_route slices with --only. Both report 20 limit problems: 12 for Flash Next and eight for prefix_5/_6.
    These runs do not pass the limits gate.

A question for you: prefix_5 and _6 run at 320 and 384 threads. They are tensor-op kernels with no
[[max_total_threads_per_threadgroup]] in the source, which docs/kernel-contract.md says should stay at 256, so
the limits gate flags them on M1 and M2. I registered them as pending, beside Flash Next's. Would you rather have the
reservation in attention_mpp.metal, or a limits rule that skips launches that only run on tensor units?

Not run here (an M4 Pro, no tensor units): a 35B prompt pass past 128 rows, and prefix_5/_6 through gates 1-4. The
fixes for both are from the source and the plans; the M5 run I asked for on item 3 would cover them, ideally with a
long prompt served fresh, resumed from the cache, and after a failed request.

Next: the rest of item 4 (per-layer stage dumps on Metal, CUDA and mlx_lm against a float64 reference), then item 1.

@cshintov

Copy link
Copy Markdown
Contributor Author

On the zig-windows failure on this PR: it is not this branch's code. It is a pre-existing breakage on main (d1018b31 → e658ac9), which I confirmed by reproducing it on an unmodified main clone.

zig build -Dtarget=x86_64-windows on main fails with three compile errors, all the same cause: Zig 0.17.0's std.c.timespec is void on Windows and its CRT has no nanosleep, so a POSIX-only construct is instantiated for the Windows target.

  • cuda/roce.zig:456 — the proxy's 20 us nap builds a timespec literal
  • tests/cuda/exl3_tp2_tests.zig:344 — @intCast(socket()), where Windows returns SOCKET (usize)
  • tests/cuda/flashnext_rank_tests.zig:48 — the injected 0.6 s delay builds a timespec literal

None of these files are touched by this PR (git diff --name-only d1018b31...69c412d has no overlap). Every branch's No GPU run fails the same job, so it is not specific to this branch.

#1193 carries the three fixes against main, and its zig-windows job passes (8m58s). digest_key.zig and speech.zig were already fixed in 1.1.2, so those are not part of it.

Once #1193 lands, rebasing this branch onto the new main should turn zig-windows green here with no change to this PR's own files.

@cshintov

Copy link
Copy Markdown
Contributor Author

Item 4: the staged references fit the near-tie explanation. Every top-1 difference from a float64 forward, by
Metal, mlx_lm, CUDA and the original mlx_lm reference tokens, falls on the 5 long-prompt positions (of 72) where
float64's top two logits are less than 2 bf16 steps apart. Wherever the float64 margin is wider, all of them agree.
This shows the misses are near ties; it does not show that the head's bf16 rounding alone picks the winner. At 54,
for one, float64's head applied to Metal's own final norm already prefers Metal's token, so that flip comes from the
error built up through the layers.

The tools (commits 2f6da408 and 1f9a2e5f)

  • families/qwen3_5/stages.zig: one stage vocabulary for the family, the residual stream after the embedding,
    after each layer's mixer and MLP, the final norm and the logits, written as DIR/<stage>.bf16 plus
    index.json. Off unless asked for: tf-qwen27-run --stages DIR on Metal, `tf-qwen35-cuda stages ` on CUDA. Both engines claim rows through the same bookkeeping and admit only one stream's linear chain with its head, refused before the round runs. Metal copies on the GPU; CUDA downloads once its stream drains and runs uncaptured while dumping, as it does for a learner.
  • tools/zig/qwen36_f64.py: a float64 forward from mlx-community/Qwen3.6-35B-A3B-4bit @ 38740b84, with the
    4-bit weights dequantized exactly.
  • tools/zig/qwen36_stage_ref.py: measures each stage two ways. Local: float64 fed the engine's own bf16 input, so
    it is that stage's error alone. Chained: float64 from the token ids, so it is the error accumulated so far. It
    takes every plane's shape from the model and the positions, and refuses a dump that differs.
  • tools/zig/qwen36_mlx_stages.py: dumps mlx_lm's generate_step path in the same layout.

One stage's error (long prompt, 378 prompt rows plus 72 teacher-forced rows; median relative error per stage)

  • Metal 0.23% and mlx_lm 0.31%, averaged over the 80 mixer and MLP stages. Metal is lower at all 80.
  • The accumulated (chained) error grows with depth for both, to 12.5% for Metal and 14.3% for mlx_lm at the final
    norm.
  • A lead, not a finding: Metal's rows with the largest error after an expert block all sit where float64's 8th and
    9th router logits are within 0.01 of each other (260 of 298 within 0.003), which points to a different 8th
    expert. For mlx_lm it holds for 192 of 320. I did not record the engines' own expert picks.

Top-1 against float64 (72 teacher-forced positions)

Engine Matches float64
CUDA (tf-qwen35-cuda, logits only) 71
Metal 69
mlx_lm, this week's stage-dump run (mlx_lm 0.31.3 on MLX 0.32.2) 68
mlx_lm, the original reference tokens 68

The 5 positions with a float64 margin under 2 bf16 steps (a step is 0.125 at these logit sizes):

Position Float64 picks Margin (steps) Disagrees with float64
24 1324 over 9506 0.09 Metal, the reference tokens
37 31639 over 5215 0.17 every engine
39 1472 over 91291 1.15 both mlx_lm runs
44 310 over 11 0.86 both mlx_lm runs
54 310 over 685 1.49 Metal, the stage-dump run

Your positions 24, 39, 44 and 54 are four of these five. The two mlx_lm runs also disagree with each other, at 24
and 54; a second stage-dump run reproduces the first byte for byte. I have no record that pins what differs between
the stage-dump run and the run that made the reference tokens, so I am not attributing it to a version.

Metal's pick at 24 moved since my 10-20 comment. On 157055b7 Metal picked 1324, as CUDA and float64 do; since the
rebase onto main it picks 9506, by one bf16 step (20.125 against 20.0). This is the change I reported with item 2:
main's 128-key decode chunks below 8,192 keys move the long prompt's logits by bf16 steps, and with fine_until set
to 0 all 72 picks match 157055b7 again. The family move and the stage hooks keep top-2 bit for bit (the rebased
run, 69c412d5 and 2f6da408 with --stages give identical top-2 at every position). So Metal now matches CUDA at
70 of 72 positions; the two it differs at, 24 and 54, are both near ties.

Checks

  • Dumping changes nothing on Metal: tokens, top-2 and the prompt hash match a run without --stages. Dumps taken
    with 16-row and 128-row prompt chunks are byte-identical across all 84 stage files.
  • The bookkeeping's test runs every sequence of four round events (begin, a full round with no head, the last row's
    or every row's, an odd head, a partial round, end, and an allocation failure at three depths) against a model of
    the planes and positions that should land. A round lands whole or not at all and ends once; each fails the test
    with its fix taken out. A recording failure after the GPU has run now ends the Metal runner, like a GPU error;
    that path has no test, since it needs a failure inside a real Metal round.
  • test-qwen27, test-qwen35, test-native and the qwen35 host steps pass, tf-qwen35-cuda cross-compiles for Linux,
    and the comparer's tests pass, including 26 dumps whose files match their own wrong index.

Not run yet: the CUDA stage dump. Its code compiles, but I have not had a GB10 window for it since writing it;
the CUDA row above comes from the earlier logits-only run of main's tf-qwen35-cuda (76aafcf) on a GB10. I will
add the CUDA stage table here once it runs.

Next: item 1, MTP drafts in the shared lanes, then the image tower.

@cshintov

Copy link
Copy Markdown
Contributor Author

Item 4, the CUDA stage dump: CUDA's per-stage error is in line with Metal's, a little lower on average, and its one
top-1 miss is position 37, the near tie every engine misses.

Run (GB10, tf-qwen35-cuda <model> stages, same checkpoint, shards sha256-checked against the Mac's)

  • The stage dump's logits equal a plain step run's bit for bit, and step equals the 10-19 build of main's
    tf-qwen35-cuda (76aafcf) bit for bit.
  • TF_Q35_BESIDE=1 with stages is refused with StagesNeedOneStream before the forward runs; no stage files are
    written.
  • The first run found a bug in 1f9a2e5f: Engine.init builds the CUDA engine from undefined and sets each field,
    and the new stages field was never set, so the warm-up forward called through it and the binary died with a bus
    error. 1ad9048f sets it to null, beside learning and mlp. Any qwen3_5 CUDA serve built from 1f9a2e5f would
    crash the same way.

One stage's error (long prompt, 378 prompt rows plus 72 teacher-forced rows; median relative error per stage,
averaged over the 80 mixer and MLP stages)

Engine One stage (local) Accumulated at the final norm (chained)
CUDA 0.23% (0.227) 12.2%
Metal 0.23% (0.231) 12.5%
mlx_lm 0.31% 14.3%

CUDA is lower than Metal at 59 of the 80 stages and lower than mlx_lm at all 80. CUDA's and Metal's logits stage
errors are the same, 0.17%. CUDA's rows with the largest error after an expert block show the same pattern as
Metal's: all 311 have a float64 8th/9th router-logit gap under 0.01 (264 under 0.003). As before, that is a lead,
since the engines' own expert picks are not recorded.

Top-1 against float64 (72 positions): CUDA 71, Metal 69, mlx_lm stage-dump run 68, the reference tokens 68.
CUDA misses only position 37, where float64's margin is 0.17 bf16 steps and every engine picks the same other token.

@cshintov
cshintov force-pushed the feature/qwen36-moe-metal branch 9 times, most recently from 9047aa7 to b318def Compare October 25, 2026 03:34
…hs under 4,096

Each lane takes four 32-wide steps, which assumed GLM's 4,096. At 2,048 the last lanes read past the router's rows. At 4,096 the guard is never taken, so GLM's bits are unchanged.
…ts (ashhart#931, slice 1)

One engine for both models: each layer's MLP is a tagged union, and forward.post switches on it; the dense arm is the 27B's code as it was. Routing is ashhart#856's rule: fp32 logits, top-k by logit with ties to the lower id, exp(l - l_top) weights rounded to bf16, the shared expert as a slot weighted bf16(sigmoid(bf16(gate))). The experts run on core/tile_qmm's tiles and their decode twin.

The attention head counts, the K/V unpack width, the K/V cache size and the shape check read the config. The attention kernels take Q27_GROUP and Q27_KV_HEADS defines that default to the 27B's 6 and 4, so the 27B's geometry is unchanged. The 35B refuses --drafter (slice 3) and --vision (slice 2).
…rnel at every served shape

Select, sort and combine at the 35B-A3B's shapes against CPU oracles, with the selection's stated NaN rule; select and combine declare their fp32 sums for gate 4. M1 and M2 limits from the offline compile.

tf_route_logits at GLM's router and indexer gate (decode and prompt chunks to 16,384 rows) and the 35B's router (to 128), against float64, with gate-5 launch records. Its 256 threads are reserved in the source: the translator gives it no M1/M2 limit, and every caller launches 256.
Decode rows and prompt rows for the 27B (24 query heads over 4 KV heads) and the 35B-A3B (16 over 2), driven through the engine's own Attention.encode so the contract checks the launches the engine makes. Float64 gated attention is the reference; gate 4's inputs make fp16 and bf16 score sums miss. Pack, append and keep compare bit-exact, keep also a second time on the caches the first left. Controls: a row seeing one key too far, a merge dropping chunks, an append one row late, and on tensor-unit chips a lost tile edge.

A plan check on every chip shows the decode shapes plan prefix_1 to _4 for chains and _2 to _4 for trees, and a tree over two tail chunks; it reads the engine's plans, not an M5's launches. Gate-5 records for every kernel at both shapes; M1 and M2 limits from the offline compile.

Run on an M4 Pro only. Not run: the prompt kernel, the prefix path and the lost-edge control, which need an M5's tensor units. Predicted from the source, not observed: on an M5, gate 1 on prompt rows may give NaN where float64 gives +Inf, a masked key's -Inf value times probability 0. Gate 1 plants no nonfinite input in the fused prefix.
… 27B and the 35B-A3B

qwen27 had its own parse of config.json beside families/qwen3_5/config.zig, the reader the CUDA and HIP engines use. It now maps the family's Spec instead, reached through core like moe_route. The Metal engine keeps its own refusals: a tied head, a MoE field on a dense model type, a zero width, and the *_text model types it accepts.

The family check refused uneven head groups by dividing, so a config with zero key heads or KV heads crashed it; both are refused first now.

The real 27B, 35B-A3B and 27B-MTP configs parse to the same Config before and after. On 1,580 single-field edits of them, 219 differ, all on malformed values: floats such as 1.5 are truncated as the Python reference does (every width is then pinned by supported()); null reads as absent; values beside the ones used are checked too; and end ids are the root's, the text tower's and generation_config's, as on CUDA.
The experts' tensor checks, router packing and tile views move from qwen27/moe_weights.zig to qwen3_5/metal_experts.zig. They take the expert sizes and widths as plain values instead of qwen27's Config. The 27B's engine still loads them in its one layer loop (gpu_weights.zig's Mlp union), reaching the file through core like the family config.

The 35B-A3B's teacher-forced top-2 is byte-identical to the step before on both prompts (72 and 60 positions). The qwen27 contract gates pass, and test-qwen27 and test-native pass.
…ense 27B's engine

families/qwen3_5/metal_native_moe.zig names the qwen3_5_moe model type on Metal: its display name, its qualified format, and the flags refused until later slices (the checkpoint's MTP drafts, images). It is the twin of cuda_native_moe.zig. native/metal.zig reads it instead of comparing model-type strings in six places, and still opens the model through the dense 27B's openQwen27 and lane host.

Refusal and log words are unchanged; test-native's refusal tests pass as before.
The rebase onto main brought registry.zig to 628 lines with qwen27's 38 records, past the 600-line rule. They move to registry_qwen27.zig, joined to registry.metal the way registry_media.zig joins registry.cuda. Two new family files' headers become one line each.
Main sizes the frame to max(rows asked, the prompt pass's rows): 512 by default on tensor units, up to 2,048 with TF_PROMPT_ROWS. The expert stage kept the 128 rows the native server asks for, so on an M5 a 35B prompt chunk past 128 rows failed with BadMoeRows. The stage now takes the frame's capacity.

The contract's expert kernels and the 35B router's logits are checked at 512 and 2,048 rows too.
Main launches q27_attn_prefix_5 and _6 when a cache has 64 chunks or more. The contract registered and planned prefix_1 to _4 only, and its reach check clamped every plan to 4 tiles. attention.prefixTiles is now the one rule both the launcher and the reach check read; the decode shapes add 5 and 6 tiles at 8,190 and 8,191 cached keys (trees and chains) for both head counts. With the old 4-tile rule put back, reach fails for both head counts.

Their 320- and 384-thread records are pending (registry_qwen27.pending, in all_metal beside Flash Next's): they are tensor-op kernels past 256 threads with no reservation in the source, as main launches them, so the limits gate flags them on M1/M2 until the source reserves the threads or the rule exempts launches that only run on tensor units.
…tions instead of crashing

geometry() divided the hidden width by num_attention_heads when head_dim is absent, null or 0, so a zero head count crashed. mrope() added the three sections and doubled the sum unchecked, so sections near the 64-bit limit overflowed. Both now return the reader's ordinary errors. The Metal engine reaches this reader since it reads config.json through the family; its own parser had refused both before any arithmetic.

The tests enumerate every mix of head count, hidden width and head_dim form, and every mix of small, exact and 64-bit-limit sections; with either fix taken out, they crash.
…orward

One stage vocabulary for the family (families/qwen3_5/stages.zig): the
residual stream after the embedding, after each layer's mixer and MLP, the
final norm and the logits, in one on-disk layout. Off unless asked for:
tf-qwen27-run --stages DIR and tf-qwen35-cuda <model> stages <ids> <dir>.
Both engines claim planes through the same Round; Metal copies on the GPU,
CUDA downloads once its stream drains (and runs uncaptured, as a learner).

tools/zig/qwen36_stage_ref.py measures each stage against float64 from the
checkpoint's exact weights: fed the engine's own input (that stage's error)
and chained from the ids (the accumulated error). qwen36_mlx_stages.py dumps
mlx_lm's generate_step path in the same layout.

Long prompt (378 + 72 teacher rows), median relative error of one stage:
Metal 0.23%, mlx_lm 0.32% (mean over stages). Every top-1 difference from
float64, by Metal, mlx_lm, CUDA and the mlx_lm reference tokens, falls on the
5 of 72 positions whose float64 margin is under 2 bf16 steps.
…round whole or not at all

A dump labels rows by consecutive position, so both engines now ask one
rule (stages.admit) before a round runs: one stream, a linear chain of
rows, and the head. Metal used to fence only the window count, after the
round's state was prepared; a one-window tree was labelled as a chain.
CUDA refused a beside stream only mid-forward and skipped the hooks for a
hidden read.

The recorder reserves every plane and position list before it appends
any, and a round's marks are spent when it ends, so a repeated end is
refused. A recording failure after Metal's GPU work ends the runner like
a GPU error does, instead of leaving a retry that would replay committed
recurrence.

The sequence test now runs every four of: begin, a full round with each
head, an odd head, a partial round, end, and an allocation failure at
three depths, against a model of the bytes and positions that land; it
reaches landed rounds and refused ones (the old four-mark bound never
landed one). The comparer takes every plane's shape from the model and
the positions, not from the dump's own index.
Engine.init builds the engine from undefined and sets each field, so the
new stages field kept garbage, and the warm-up forward inside init called
through it: tf-qwen35-cuda died with a bus error at its first forward on
a GB10. Found by the first CUDA stage run; the field now starts null like
learning and mlp beside it.
@cshintov

Copy link
Copy Markdown
Contributor Author

#1125 (Ornith, Qwen3.5-MoE in the qwen27 Metal engine), which you're carrying onto main, overlaps 16 of this PR's files, including the expert kernel, qwen27/config.zig, gpu_weights.zig and decode_round.zig. So the engine keeps one expert path, I'll rebase onto #1125's once it lands and cut this PR down to what the 35B-A3B adds on top: drafting with its own MTP layer through the shared lanes, its attention registrations in the kernel contract, and the staged references against the float64 forward. The MTP drafter and image work for ask 1 wait locally until then. If you'd rather Ornith build on this PR's expert path instead, say so and I'll keep it.

@ashhart

ashhart commented Oct 26, 2026

Copy link
Copy Markdown
Owner

Review of the current head rebased onto main e9b69ed (it carries, with conflicts in native Metal admission resolved and two attention constructor calls updated for main's API). Four things before device qualification:

  1. Router values. Admission takes 8-bit g64 routers, then load expands each affine weight and rounds it to BF16 for the BF16 router kernel (qwen27/widths.zig:67, qwen3_5/metal_experts.zig:73, :106). That changes stored values: scale 1/256, bias 1, code 1 is 1.00390625, and this path stores 1.0. Please run the router on the stored affine weights with the right accumulator, as the experts already do (metal_experts.zig:120), or show the checkpoint itself defines that BF16 materialisation.
  2. Final-stage label. Stages.finish labels the residual (s.hidden) as .final, but the head reads the normalised input (qwen27/forward.zig:136, :146), and qwen36_stage_ref.py treats final as the head input. Capture f.get(.input) for .final, keep the residual for .block, and add a non-unit-norm fixture. It's diagnostic and off by default, but it invalidates the staged comparison.
  3. Attention contract. The new runner refuses prompt contracts before tensor-unit GPUs and omits attention_prompt_sg (metal_q27_attn.zig:44, :279), which main's runtime uses before M5. Please add the 35B prompt-SG cases and launch records on non-M5 chips, cover prompt heights above 128 rows, and finish the pending 5/6-tile prefix records. On M5, the masked P·V still multiplies a masked tile (attention_mpp.metal:85, :100): test the zero-times-Inf case against the reference and mask the value contribution, or refuse the cell.
  4. Lean. zig/src/native/metal.zig reaches 617 lines (one new finding; zero new is the rule). Please extract the MoE admission into its own unit.

Then: the corrected stage comparison on current heads with all teacher-forced top-two margins and worst-column FP64 error by projection class, and old-family A/B (27B DFlash/cache identity, CUDA/HIP config). Text-without-MTP as the first slice is fine.

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