Skip to content

qwen3_5 cuda: serve ExLlamaV3 (EXL3) Qwen3.5-MoE checkpoints - #1150

Closed
MiaAI-Lab wants to merge 3 commits into
ashhart:mainfrom
MiaAI-Lab:exl3-qwen35-moe
Closed

MiaAI-Lab wants to merge 3 commits into
ashhart:mainfrom
MiaAI-Lab:exl3-qwen35-moe

Conversation

@MiaAI-Lab

@MiaAI-Lab MiaAI-Lab commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Serves ExLlamaV3 (EXL3) Qwen3.5-MoE checkpoints on the native CUDA engine (tracking issue: #1149). The format and kernel design are turboderp's ExLlamaV3 (https://github.com/turboderp-org/exllamav3); the checkpoint exercised is https://huggingface.co/Mia-AiLab/Ornith-1.5-122B-EXL3-EAQv1-4-bpw. Three commits, each building:

  1. qwen35_bf16_rows.cu and cuda_bf16_rows.zig: a bf16 embedding-row copy and a small bf16 dense projection (GDN in_proj_a / in_proj_b) with a fixed summation order. Pending contract records only.
  2. exl3.Dense.reserve: grows the dense scratch before graph capture so no allocation happens inside a capture.
  3. The loader: cuda_geometry.zig accepts quant_method "exl3", cuda_exl3_weights.zig reads the HF tensor names (trellis projections and every expert kept in their stored layout, f16 router and in_proj_a/b converted to bf16, zero-centred RMSNorm weights shifted by one), and cuda_engine.zig routes the new projection kinds to the existing cuda.exl3 kernels. The weights load runs twice over one code path: a dry run sizes the arena, then the real pass.

--drafter is refused for an EXL3 checkpoint (Exl3DrafterUnsupported): its MTP layer and a DFlash2 drafter are not read by this change.

Related open PRs, not duplicated: #1137 (BF16 scoring) also adds a cuda_bf16 file and edits geometry, engine and Proj, so mine are named *_bf16_rows with .x3 / .bf projection kinds; whichever lands second will need a small textual merge. #1145 (ModelOpt NVFP4) edits the same geometry and engine regions. #1113 (EXL3 Flash Next) is a different family. #1047 touches cuda_routed.zig.

Receipt

  • Environment: TensorFold d1018b3 plus this branch; GB10 (sm_121), 121 GiB; nvcr.io/nvidia/pytorch:26.07-py3 (nvcc 13.x), Zig 0.17.0; checkpoint Mia-AiLab/Ornith-1.5-122B-EXL3-EAQv1-4-bpw @ 9c5c913b3de1eef901520d6fedaf450044ea0423.
  • Exactness: GPU smoke on a GB10 (121 GiB), NVIDIA driver 580.178.04, container nvcr.io/nvidia/pytorch:26.07-py3 with Zig 0.17.0, checkpoint Mia-AiLab/Ornith-1.5-122B-EXL3-EAQv1-4-bpw at revision 9c5c913b3de1eef901520d6fedaf450044ea0423. Flags --no-drafts --no-thinking --parallel 1 --context 8192 --kv-cache fp8 --vision, temperature 0, 64 tokens, five requests: three text prompts, one image, one tool call (get_weather, not forced). The reply text, finish reason and tool call arguments of this branch's build equal those of a build of the same engine carrying my local, unsubmitted speed patches. That is a consistency check between two builds, not a quality evaluation. Drafted-versus-plain and concurrent-versus-solo checks were not run.
  • Decode speed before and after: n/a (a new format; nothing existing changes). Not measured for the new path, which uses the generic EXL3 kernels and is not tuned.
  • Prompt speed before and after: n/a for the same reason.
  • Tests run (no GPU, in the container above): zig build test on this branch: 211/211 steps succeeded, 1835 of 1847 tests passed, 12 skipped; upstream main with the same command: 211/211, 2102 of 2110, 8 skipped (the totals differ because cached unchanged steps are not rerun). zig fmt --check build.zig zig clean. tools/zig/lean_check.py: 444 problems before and after. New host tests (cuda_exl3_admission_test.zig): an EXL3 config is admitted by quant_method alone, and configs with neither an MLX table nor exl3 are refused; a deliberately wrong expectation made test-cuda-host fail, so they run.
  • Not run, and why: no kernel contract test for the two new launches (only pending records, as the other qwen35 launches), no prompt/decode benchmarks, no Metal, no multi-stream or long-context runs (context 8192 only), no 262k window.

Limits

Serving works with the generic EXL3 kernels; prompt and decode speed are modest and not tuned for this model, and there is no drafter. Only the MoE model without a learning pocket is admitted.

Checklist

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off. (Drafts are refused for EXL3 here; the smoke runs used --no-drafts.)
  • Drafted output equals "draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. (Not run: no drafter, and one stream only.)
  • No precision traded for speed. The bf16 dense kernel sums in fp32 in a fixed order; the EXL3 kernels are the existing ones.
  • A new or changed GPU kernel is registered with the kernel contract. (Two launches are listed as pending records only; no contract test yet, see below.)
  • Prompt processing is no slower than the last release. (n/a: a new format, nothing existing changes.)
  • Platforms: CUDA on GB10 (sm_121) only. Metal and other GPUs not run.
  • New tests fail before the change, pass after it. (Host test added; see below.)
  • 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.

Adds qwen35_bf16_rows.cu and its launcher (cuda_bf16_rows.zig). tf_q35_embed_bf16 copies the rows of a bf16 embedding table, with the engine's padding-row convention (rows at or past params[2] are left untouched). tf_q35_dense_bf16 is a bf16-in, bf16-out dense projection for small matrices such as the GDN in_proj_a and in_proj_b: one block reduces one output in a fixed order (fp32 fused multiply-adds per thread, a fixed shuffle tree, then the eight warp sums in order), so a row's bits do not depend on how many rows share the launch.

Nothing calls them yet; the EXL3 loader in a later commit of this series does. Both launches are listed as pending contract records beside the other Qwen3.5 launches.
Dense.reserve grows the split-K sums, the counters and the fp16 input staging to what 128 rows of a given weight need. The buffers otherwise grow on first use, which allocates inside a CUDA graph capture when a weight is first called there. The EXL3 loader in the next commit calls it for every projection it loads.
Reads a Hugging Face-layout Qwen3.5-MoE checkpoint whose config.json says quantization_config.quant_method "exl3" (ExLlamaV3's trellis format, mul1 codebook, with each tensor's width taken from its trellis shape). Projections and every routed and shared expert stay in their stored layout and run on the existing cuda.exl3 Dense and exl3_experts grouped kernels; embeddings stay bf16, the f16 router and GDN in_proj_a/in_proj_b become bf16 at load, and the zero-centred RMSNorm weights are shifted by one into the engine's form.

- cuda_geometry.zig accepts quant_method "exl3" in place of the MLX `quantization` table. Only the routed (MoE) model without a learning pocket is admitted; per-tensor widths come from the trellis shapes at load.
- cuda_exl3_weights.zig loads the checkpoint twice over one code path: a dry run sizes the arena, then the real pass uploads. The kit holds the EXL3 dense and grouped-expert kernels and the bf16 rows; their scratch is reserved before any graph capture.
- cuda_engine.zig routes `.x3` and `.bf` projections to those kernels at any row count and embeds from the bf16 table.
- An EXL3 checkpoint refuses --drafter for now (Exl3DrafterUnsupported): its MTP layer and DFlash2 drafter are not read by this change.

The format and the kernel design are turboderp's ExLlamaV3 (https://github.com/turboderp-org/exllamav3); this repository already carries its Dense and grouped-expert kernels in cuda/exl3.zig. The checkpoint this was exercised on is Mia-AiLab/Ornith-1.5-122B-EXL3-EAQv1-4-bpw. Prompt and decode speed use the existing generic EXL3 kernels and are not tuned for this model.
@ashhart

ashhart commented Oct 22, 2026

Copy link
Copy Markdown
Owner

Thanks @MiaAI-Lab for the EXL3 Qwen3.5-MoE loader, for reusing the shared EXL3 kernels, and for being clear about what the GB10 smoke run does and does not show. We are carrying your three commits onto current main ourselves, kept as yours, porting the routed MoE calls to main's params and routeRows, and a separate commit of ours will add kernel contract cases for tf_q35_embed_bf16 and tf_q35_dense_bf16 (zig/kernels/cuda/qwen35_bf16_rows.cu:10 and :24) in place of the pending entries, keep the kit's errdefer (zig/src/families/qwen3_5/cuda_engine.zig:229) alive past its if block so a later init failure cannot leak it, and refuse trellis widths outside 2 to 16 at load (zig/src/families/qwen3_5/cuda_exl3_weights.zig:131). The same commit sets e.kit = null in the hand-built Engine at zig/qwen35_draft_frame_host.zig:112, so the two Debug host targets stop crashing, and splits cuda_engine.zig to stay under 600 lines. Then it goes to the GB10 kernel contract and served-equality runs (prompt chunks, resumed against fresh, concurrent against solo), which also give the two launches their measured records, and lands. You need not rebase; if you are in the middle of a change, please tell us so we do not collide.

- Synthema, Ash's AI Assistant.

@ashhart

ashhart commented Oct 25, 2026

Copy link
Copy Markdown
Owner

Landed on main, with your three records under your authorship and four maintainer children. Thank you, @MiaAI-Lab.

What the children do:

  • Close EXL3 owners and qualify stored-format admission (the fix-up we'd prepared).
  • Record nvcc 13.0 resources for the two qwen35_bf16_rows launches, measured on sm 80/86/89/120/121.
  • Keep ModelOpt (qwen3_5 cuda: Qwen3.8-27B ModelOpt and compressed-tensors NVFP4 checkpoints, weight-only and in their own math #1145, which landed just before) and EXL3 as separate formats.
  • Slice the EXL3 .bf projections at the bf16 row kernel's 128-row limit. Before this, every prompt of 2,048 tokens or more failed at once with RowShape on the Ornith checkpoint. Your smoke used short prompts, so it didn't show up there.

Measured on a GB10:

  • Mia-AiLab/Ornith-1.5-122B-EXL3-EAQv1-4-bpw @ 9c5c913b: loads at 67.4 GiB. Prompts of 512 to 8k qualify (8k in about 12.9 s, then about 29 tok/s decode on one stream), and the 1 to 8 stream sweep runs. Chat with thinking off and on qualifies.
  • Qwen3.8-27B MLX 4-bit against main: tokens identical. Prefill at 2k and 8k is flat over three alternating runs each (1319.8 → 1319.6 and 1307.0 → 1306.0 tok/s), and the sweep is flat.

- Synthema, Ash's AI Assistant.

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