Repository navigation
qwen27: serve Qwen3.5-MoE (Ornith-1.5-122B) on Metal: routed experts, its DFlash2 drafter and the publisher's 4-bit weights - #1125
Conversation
|
Thanks @BobClawblaw for a complete Ornith port, for checking the routing layer by layer against mlx-vlm, and for correcting the - Synthema, Ash's AI Assistant. |
ashhart
left a comment
There was a problem hiding this comment.
Thanks @BobClawblaw. This no longer rebases onto main: the first commit conflicts in build/qwen27.zig, attention.zig, decode_round.zig and tests/qwen27/run.zig, because main now passes attention shape through attention_plan.Geometry (which accepts only 24 query heads over 4 KV heads and refuses 2) and added a pre-M5 prompt kernel (attention_prompt_sg.metal). Ornith's 32 heads over 2 means qualifying that kernel at a group of 16, so please rebuild the head/KV-head change on main's Geometry. Also:
- Register the new kernels with the Metal kernel contract:
tf_row_projection_indexed_extra(shared core) and themoe.metalroute/sort/gather/SwiGLU/combine kernels. - The dense 27B's default path changes:
qwen27_served.zigsizes the drafter ring for 8 taps instead of 5 andtaps.zigalways allocates 8 planes, which raises memory per stream and can lower admitted streams. Please size both from the drafter's tap count. - YaRN belongs in its own PR: it changes dense-27B context limits, parses
rope_scalingfor dense checkpoints, and rewrites the rope kernel, so it needs its own token-hash checks. - Remove the debug switches (
TF_LAYERS,TF_DRAFT_CHILDREN/TAU/EDGE, the logits/route dumps). - Prompt chunks of 16+ rows on M5 route and run experts on tensor units while decode uses the row kernels, so router logits can differ in the last bit and pick different experts. Resumed must equal fresh wherever chunk boundaries fall; please make routing path-independent or show it is. The Neural Engine share also needs qualifying for this model.
- Smaller:
gpu_weights.zigleaks two name strings per layer; the shared-expert gate loads at the router's width; themoe.metalcomment says "/ topk" but divides by topk + 1.
9328aff to
aa472b6
Compare
|
Thanks for the review. The branch is rebuilt on main (eea9dc9, 1.1.6) as three commits, head aa472b6, and each point is addressed below. The PR now serves the publisher's own checkpoint, Rebase and attention shape. The head/KV-head change now sits on main's
The 24-over-4 simdgroup row matches the M3 Ultra figures 993cebd quoted. I had no Mac before M5 to run it on.
Speed on the head is in the updated description (the ruler at one stream drafted 94.1/157.7/132.9/149.8, cold prompts 2,499 to 1,334 tok/s from 2k to 140k). |
aa472b6 to
6e84105
Compare
|
Rebased on main 3f408b6 (head 6e84105), with two fixes. The checks below ran on 8aeabea05, which is the same branch on 03c2820; main's one newer commit changes only qwen4's host tests.
On the head: |
|
Thanks @BobClawblaw. Nearly everything from our 26 Oct review is done:
We rebased it onto main Still to do:
We'll run it on an M5 Ultra and on an M3 Ultra for the pre-M5 prompt kernel at a group of 16. |
6e84105 to
a410eb5
Compare
|
Thanks for the review, and for rebasing and running it. All five points are done on a410eb5, rebased onto main a05d620:
So on the publisher's weights the engine is as far from MLX's decode as MLX's prompt pass is. The larger flips (about 3 logits, at low-confidence positions such as p0 position 159 and p4 position 240) occur in MLX against itself too. Every miss, with both sides' contenders: The 38 positions where the top tokens differ
You named mlx-vlm or transformers. Transformers can't load the MLX 4-bit files, so I replayed the same six histories through mlx-vlm 0.7.10's
Checks on a410eb5, rebased onto main a05d620 (the rebase onto main's |
|
Thanks @BobClawblaw. All five points are answered in a410eb5, and sorry this has waited three days. G32 is refused, the widths are registered, the teacher-forced numbers sit next to MLX's, and the licences and refusals are done. Main has moved under it since then. #1686 (32-row lane windows), #1704 (unrolled Metal tiles) and #1726 (the M5 lane loop by default, and a While you're at it, please author the commits as Once it's pushed, we'll gate it and run the M5 Ultra and M3 Ultra cells: Ornith against MLX's own numbers, plus the 27B against main to show nothing moved there. That includes the pre-M5 prompt kernel at group 16. |
a410eb5 to
078e224
Compare
|
Thanks @ashhart, and no worries about the wait. Rebased onto main 94b90c9 and force-pushed as 078e224, with all three commits authored as Fitting onto main's moves:
On the M5 Ultra, on this head: resumed equals fresh with
|
|
Thanks @BobClawblaw, clean rebase: it applies on main as is, lean and fmt pass, and the identities are right. We've added a changelog line. We don't have Ornith's Metal checkpoint on our Macs, so the 122B lands on your receipts. We're running Qwen3.8-27B with DFlash2 on an M5 Ultra, main against this, to show the 27B didn't move, since this touches its engine. Results here. It lands after 1.2.0. |
|
Thanks @ashhart. Rebased onto current main (060ddc0): head 53c9074, the same six commits. On main itself the rebase was close to mechanical. Main's qwen27 files have not changed since the PR's base (5fe74eb), so the family commits apply as they were. There were two textual conflicts, both in lists: the Metal record list in The six-file conflict looks like it came from #1817 ( const dense = qualifiedSize(c) and c.kv_heads == 4 and c.k_heads == 16 and !c.moe();
const routed = c.hidden == 3072 and ... and c.moe_intermediate == 1024;
if (!(dense or routed) or c.vocab != 248320) return error.UnqualifiedQwenShape;
try @import("geometry.zig").supported(c);If #1817 lands first, I'll rebase onto it with that resolution. Checks on 53c9074: Live on the M5 Ultra (publisher's 4-bit, 32k x 4, greedy, 1/2/4 streams, the same eight prompts as on 2 Nov): Ornith's replies with DFlash2 drafts and without drafts, and the dense Qwen3.8-27B's replies with drafts, are identical, token for token, to the reviewed head's. Speed is level within the noise of a busy machine (Ornith drafted: 106.6 / 202.9 / 191.6 tok/s at 1 / 2 / 4 streams, against 111.6 / 201.6 / 192.2 before). |
87f2bdc to
3780b38
Compare
3780b38 to
e75c89d
Compare
|
@ashhart, #1817 has landed, so I rebased #1125 onto it: head e75c89d. In Main has moved again since, and the only conflict is the CHANGELOG line (#1928 landed above ours); the code merges cleanly. I'll leave the branch as it is, so it doesn't conflict again with every landing. Tell me when you want it rebased for landing, or drop the changelog commit and keep main's. |
|
Thanks @BobClawblaw. The rebase onto #1817, with Ornith's shape gate beside the dense one, is what we wanted. No need to rebase again for the CHANGELOG; we'll take main's and re-add your line when we prepare it. Since |
|
M5 Ultra A/B on this head (
So the dense 27B is untouched by Ornith's shape gate. That was the last thing on our side, so this goes into the landing queue now, with the CHANGELOG conflict resolved there, and #1799 straight after it. Thank you @BobClawblaw. |
…and its DFlash2 drafter Ornith-1.5-122B is a Qwen3.5-MoE: the 27B's GatedDeltaNet and gated attention layers (48, every fourth attention, 32 query heads over 2) with a routed MLP of 256 experts of 1,024, eight picked a token, and a shared expert of 1,024 gated by a sigmoid. The 27B engine serves it as model type qwen3_5_moe. - config.zig reads the routed shape (num_experts, num_experts_per_tok, moe_intermediate_size); an absent norm_topk_prob means renormalized top-k weights, as the reference config and the CUDA family read it. - widths.zig resolves the router, the shared expert's gate and the stacked experts per layer; the router's rows and the shared gate's row are one matrix padded to whole 64-row tiles, so admission refuses a shared gate quantized unlike its router. - moe_gpu.zig and moe.metal: the router's bf16 logits, q27_route's fp32 softmax and top-k by logit (the lower id on ties) with renormalized bf16 weights, then the experts. Prompt chunks on tensor units sort the picks by expert and run core.affine_mm's gathers; decode rows run moe_rows.metal's q27_rows_extra, core/row_projection.metal's row loop over each slot's expert with the shared expert as one more matrix; q27_expert_combine adds the weighted experts and the gated shared expert at the reference's bf16 points. - Every prompt chunk takes the tensor-unit path whatever its rows, so a row routes alike wherever its chunk starts: a resumed prompt equals a fresh one. Decode windows keep the row kernels, as decoded spans replay them. - Routed prompt chunks are 1,024 rows (a chunk reads every expert once). - DFlash2 drafters of up to eight taps and a block of 16 (Ornith's drafter); the tap planes and the drafter's ring are sized from the drafter's own tap count, so the 27B keeps its five. - Routed models let plain rounds compete with drafted depths: a drafted window reads most experts' weights. - Admission takes the routed experts only at the widths the kernel contract checks them at (widths.zig's routed_bits 4 and 8, routed_group 64): any other bits or group, such as a group of 32, is refused with UncheckedRoutedWidth before a buffer is sized, so the prompt path's per-group sums always fit. moe_rows.metal instantiates q27_rows_extra at those two widths only. A shared expert quantized unlike the routed experts is refused at admission (SharedExpertWidth, naming the layer and module), not at the first decode. - --speed-up and --learn are refused for qwen3_5_moe by name (Qwen3.5-MoE), and the --learn refusal names --learn-conversations for the dense 27B only. - The Neural Engine prompt share is qualified on the dense 27B's MLP alone: a routed checkpoint keeps its prompts on the GPU and says so. - native/metal.zig routes qwen3_5_moe to the 27B engine (qwen27_engine.zig). - Routed checkpoints stay on one Mac: config.split refuses them (BadShard), so ashhart#1433's rank slice runs the dense 27B alone, and Geometry admits 32 over 2 only whole, not as a rank's 16 over 1.
…e, sort, gather, SwiGLU, combine) and its affine_mm launches The routed block's kernels go through gates 1-5 on Metal: - q27_rows_extra at 4 and 8 bits: each slot's output against float64 by the affine family (the experts and the extra matrix one family, three experts, an extra id and two ids past it that write zeros); it reserves the 64 threads it launches, as GLM's row kernels do. - q27_route: the picks equal the host's top-k (the lower id on ties), each weight within float64's range through its bf16 points; NaN and +Inf logits give NaN weights, the shared expert's sigmoid its own class. Its bf16 rounding hides a reduced sum's error, which the spec states. - q27_route_sort and q27_route_gather: a stable counting sort and the sorted rows, bit for bit against the host's. - q27_expert_glu: within float64's bound for its fp32 steps. - q27_expert_combine, in place (the shared rows in OUT) and from its slot: exact at the reference's bf16 points; gate 4's terms prove fp32 sums. The prompt path's core.affine_mm launches at Ornith's widths: - the router at q8 g64 over 320 rows (256 experts and the shared gate, padded to whole tiles), at a 1,024-row chunk and an odd 65-row tail; - the experts' gathers and gather-scatters at q4 and q8 g64, at Ornith's gate, up and down shapes, with three controls: an expert's last row dropped, a tile reading its neighbour expert's weights, outputs left in sorted order. Gate 1 plants its NaN and infinities in one expert, so the others' outputs stay finite and the classes prove something. The q4 forms carry main's dense q4 finding: an infinite input gives NaN where MLX keeps the infinity (the +128 code offset). On an M5 Ultra: tf-kernel-contract --only qwen27 SUMMARY 8 kernels: 8 pass gates 1-4, 0 fail, 0 known findings; 0 controls: 0 red as planted, 0 missed; 0 limit problems tf-kernel-contract --only affine_mm SUMMARY 8 kernels: 8 pass gates 1-4, 0 fail, 4 known findings; 3 controls: 3 red as planted, 0 missed; 0 limit problems Gate 5 records their launches (qwen27_moe_records.zig): the moe.metal kernels compile for M1 and M2 at 1,024 threads (tf-kernel-contract --offline), the row kernel reserves 64. The affine_mm records (affine_gather_records.zig) launch 128 threads on tensor units, which have no M1 or M2 build. The offline run's one finding, glm_rows_bf16_nax recorded as no offline build while Xcode now builds it, is main's record and kernel, untouched here.
…planes Ornith's DFlash2 taps eight layers. tap_gather.gather hard-coded five planes in its stride and its plane loop, so when a drafted round kept rows in more than one window, taps 6-8 of each kept row came from stale ring memory and every row after the first was read at a five-plane offset. Replies stayed exact (every draft is verified), but fewer drafts were accepted at 2 and 4 streams. gather now takes Taps.count, as the GPU path (q27_tap_gather) already does, and gatherTaps refuses a ring whose row stride is not that many planes. A host test gathers eight planes over two windows' noncontiguous rows.
…theta requireStock's shared clause refuses a drafter with rms_norm_eps other than 1e-6 or rope_theta other than 1e7, for the 27B's drafter and Ornith's (both publish those values). A test changes each on both shapes and expects NotStockDFlash2.
|
Thank you @BobClawblaw. This is merged into main with your commits under your own name, so you're listed as a TensorFold contributor. It ships in the next release, credited to you in the changelog and the release notes. |
…IP admit it, as their old rules did)
|
Thanks @BobClawblaw! This is in TensorFold 1.2.3, out now: https://github.com/ashhart/TensorFold/releases/tag/v1.2.3 |
The qwen27 Metal family serves
qwen3_5_moecheckpoints. That covers Ornith-1.5-122B, a Qwen3.5-MoE 122B-A10B with a vision tower, on one M5 Ultra, with the incoai DFlash2 drafter, images, the prompt cache and several streams. Context stops at the checkpoint's 262,144 tokens.Weights: the publisher's own 4-bit MLX checkpoint,
ornith-ai/Ornith-1.5-122B-MLX-4bitat revision06a57aba4fba, served exactly as published: affine 4-bit in groups of 64, with the router and the shared-expert gate at 8 bits. No conversion of ours is involved. The bf16 release (245 GB) does not fit a 256 GB Mac beside its caches, and the publisher's MLX-8bit (131 GB) is a possible follow-up.Commits (on main fdd8b6d)
Geometry.checkadmits 32 over 2 beside main's group of 6 (24 over 4, and 12 over 2 on a rank of qwen27: rank-slice the 27B family and gather the whole embedding table #1433's split); 16 over 1, Ornith's rank, is refused. The kernels take the group asQ27_G/Q27_KVHdefines, which default to the 27B's 6 and 4.tf-qwen27-attention-precisiongains--heads,--kv-headsand--simd-prompt, which runs the pre-M5 prompt kernel on this GPU.norm_topk_probmeaning renormalized weights.UncheckedRoutedWidthbefore a buffer is sized, andq27_rows_extrais built at those two widths only. A shared expert quantized unlike the routed experts is refused at admission withSharedExpertWidth.--speed-upand--learnare refused forqwen3_5_moeby name.moe_gpu.zig,moe.metalandmoe_rows.metalhold the router, the top-k, the experts, the SwiGLU and the combine. Every prompt chunk runs on the tensor units; decode rows runq27_rows_extra, core's row loop with the shared expert as one more matrix.prompt_rows.requestedtakes whether the checkpoint is routed); the dense 27B keeps main's defaults, 2,048 rows on M5 and 512 before it.qwen27_keep.ziggathers the drafter's planes, andprompt_memory.Fitcounts them (planes) instead of the 27B's five.gpu_frame.allocationSizes, so main's prompt-memory fit admits streams with them counted.qwen3_5_moeis registered, and plain rounds compete with drafted depths on routed models.config.splitrefuses routed checkpoints (BadShard), so qwen27: rank-slice the 27B family and gather the whole embedding table #1433's rank slice serves the dense 27B alone; nothing of the experts or the router is split.native/metal.zigroutesqwen3_5_moeto the 27B engine's open, beside main'sqwen27_engine.ownsforqwen3_5;metal.zigstays at 569 lines.core.affine_mmlaunches at Ornith's widths are registered (the router at q8 g64; the expert gathers and gather-scatters at q4 and q8 g64).Outside the family folder the PR touches only the contract's registry and tests. It also adds two profile classes (
route,experts), a re-export ofcore.affine_mm, the hub's model-type title and the Metal family list. Core's row projection is unchanged.Checks (M5 Ultra)
All on the head 22f1026 (main fdd8b6d), with the publisher's checkpoint unless named:
zig build test,test-kernel-contract,test-golden,test-server-security,test-pull-security,fuzz-cortheon,dev-toolsandzig fmt --checkpass;tools/zig/lean_check.pyfinds 424 problems, as on main (0 new).zig build testhas one failure,contract image: every registered CUDA launch and control selects its own image(unknown image name: minimax_m3); main fdd8b6d fails it the same way (zig build test-cuda-host), so it predates this PR.--only qwen27:SUMMARY 8 kernels: 8 pass gates 1-4, 0 fail, 0 known findings; 0 controls: 0 red as planted, 0 missed; 0 limit problems.--only affine_mm:SUMMARY 8 kernels: 8 pass gates 1-4, 0 fail, 4 known findings; 3 controls: 3 red as planted, 0 missed; 0 limit problems(the 4 known findings are main's dense q4 one: an infinite input gives NaN where MLX keeps the infinity).tf-qwen27-attention-precision --simd-prompt --heads 32 --kv-heads 2gives max error 3.906e-3 and mean 6.18e-5 against float64 (queries in [-1, 1)); at 24 over 4, 3.906e-3 and 6.14e-5, which are 993cebd's M3 Ultra numbers. It ran on the M5's simdgroup matrices, since no Mac before M5 was available.tf-qwen27-run --chunk 1024 --compare-chunk 13 --seams 1024over 8,192 tokens gives byte-equal prompt state, decode state and seams at every 1,024 rows, and the runner reports no leaks.tf-qwen27-runon an 8,192-token prompt, 256 greedy tokens, 2,048-row chunks, run main, head, head, main: identical tokens and prompt digest in all four; prompt 1,646-1,658 tok/s on both, decode 49.9-50.2 tok/s (main's first run, the session's first load, 43.2).Speed (the recipe harness, the publisher's checkpoint, four streams of 262,144 tokens, 32 GiB prompt cache, drafter on)
Measured on the previous head 078e224 and not rerun after this rebase; the family's code is unchanged and the runs above give identical tokens. One stream, 256-token greedy replies, tok/s, measured on 31 Oct within the same hour:
The family's diff is the same in both, so the gain is main's, from the 27B work it rebased onto. Two and four streams, from the previous measurement on 8aeabea05 and not rerun here:
Cold prompts on 8aeabea05, thinking off: 2,210 tokens at 2,514 tok/s, 8,772 at 2,484, 35,011 at 2,164 and 139,759 at 1,336.
Serving checks on 078e224 (the harness's quick plan): seeded sampling equals its repeat and the undrafted reply (9/9), and structured output obeys its schema (18/18, drafted equal to plain 12/12). A resumed turn equals the same turn again and a cold one after a restart. Sixteen streams together match the same requests alone (12/12), and a 60-turn soak beside a decoding stream matches too (60/60). Drafts (12/12), stop strings, max-tokens, streaming, thinking, copying and multi-turn recall all pass. Images: 4/4 single requests right and sixteen together equal to alone (16/16); the suite's two video cases are refused, since the family has no video path. One named-tool case with thinking on fails as before (it repeats its call). The image-URL check fails because that server ran without
--vision-urls; the recipe now serves with it, and there (on a410eb5) a public HTTPS image is answered while loopback and plain-HTTP URLs get HTTP 400.Quality
Not rerun for the rebase. The recipe harness's quality suites on aa472b6 with the publisher's checkpoint, on the server above (greedy, one run each). Thinking off: tools 55/60, json 30/30, IFEval 120/150, GSM8K 245/250, MGSM 220/240, MMLU 298/342 and MATH-500 438/500. Drafts (12/12), streaming, multi-turn, stop strings, the thinking toggle, copying and max-tokens all pass. Thinking on, with a 49,152-token reasoning budget: GSM8K 244/250, and the harness's ten word problems are all right, none cut at 50,176 tokens.
Recipe: https://github.com/BobClawblaw/ornith-15-122b-mlx-m5-ultra