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 4 commits into
ashhart:mainfrom
cshintov:feature/qwen36-moe-metal
Draft

cshintov wants to merge 4 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.

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.
  • The qwen3_5 family, where Qwen3.6-35B-A3B on CUDA: routed experts and its MTP layer drafting chains (on #836) #856 puts the experts on CUDA: on Metal it is the 2B's engine, with no prompt
    cache, drafter or image path, so slices 2 and 3 would be rebuilt there.
  • 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; 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.31.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

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.

…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.
@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

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.
@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.

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