Skip to content

Qwen3.6-35B-A3B on CUDA: serve NVIDIA's NVFP4 checkpoint as downloaded - #1662

Merged
ashhart merged 7 commits into
ashhart:mainfrom
laudney:pr-q36-nvfp4
Oct 31, 2026
Merged

ashhart merged 7 commits into
ashhart:mainfrom
laudney:pr-q36-nvfp4

Conversation

@laudney

@laudney laudney commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1656 (1 of 3: NVFP4, then DFlash2, then a 1M window with YaRN).

Why

Serving the weights a lab or NVIDIA actually publishes is what makes TensorFold benchmarkable and adoptable:

  • Benchmarking. nvidia/Qwen3.6-35B-A3B-NVFP4 is the checkpoint SGLang, vLLM and TensorRT-LLM serve on Blackwell. Running the same bytes is the only like-for-like comparison: no conversion step, no different quantizer, nothing to explain away in a speed or quality table.
  • Adoption. People download the frontier lab's original weights and NVIDIA's own NVFP4 exports, not a TensorFold-specific requant. Today the native CUDA engine refuses this checkpoint at load (UnsupportedQwenQuantization: the routed experts read MLX tables only), so a GB10 user has to fetch a different model before they can try TensorFold at all.

What

The dense Qwen3.8-27B already serves ModelOpt checkpoints (cuda_modelopt.zig) and the grouped NVFP4 expert kernel already exists; this PR wires the MoE to them:

  1. Test (modelopt_tests.zig, contract_nvfp4_fact.zig): two-part NVFP4 expert packing (gate and up from separate tensors into one pair table) and the shared-last stride, at Qwen's shapes.
  2. Refactor (cuda_weights.zig): the affine-4 packer splits its parts from its tensor reads, so the ModelOpt MTP drafter can pack quantized parts; MLX bytes unchanged.
  3. Serve (cuda_geometry.zig, cuda_modelopt.zig, cuda_modelopt_experts.zig, cuda_routed.zig, cuda_engine.zig): admission reads every expert projection's effective recipe (W4A16 NVFP4 routed and shared experts, FP8 attention and DeltaNet projections, NVFP4 head, bf16 router and gates) and refuses a checkpoint that quantizes one expert, the shared expert or the router differently, before loading. Experts pack into the grouped NVFP4 tables with the shared expert last, as in the MLX layout; prompt tiles use the kernel's decode-sum form, so an expert pair has the same bits in a prompt chunk, a verify window and a one-row step. Host tests in cuda_modelopt_moe_test.zig.
  4. MTP drafts (cuda_mtp_bf16.zig): the checkpoint keeps its MTP layer in bf16; without mtp-4bit.safetensors beside it, the drafter quantizes that layer to affine-4 at load, as Nemotron's ModelOpt drafter does. This changes only which rows a round proposes, never a reply; the startup log says so.
  5. Docs: a native CUDA NVFP4 section in docs/recipes/qwen3.6-moe.md, a README support row and a changelog entry.
  6. Capabilities: capabilities --json names modelopt-mixed and modelopt-nvfp4 for qwen3_5_moe on CUDA beside mlx-q4g64.

The rules (bf16 activations for the W4A16 layers, the shared expert last, the MTP layer drafting through the target's head) come from @gilby's Python port, #345.

Gate (one DGX Spark: GB10, sm_121, CUDA 13.0; checkpoint revision 1355db6a)

  • Device: tf-cuda-test modelopt passes (the two-part repack and Qwen shapes included).
  • Served, NVFP4 with its own MTP drafter: loads in about 30 s, 20.9 GiB on the device. Drafted replies equal their "draft": false runs (6 of 6: prose and code, greedy and two seeded temperatures); concurrent replies equal their solo runs (40 of 40 at one and four streams, greedy and sampled); a second turn that resumes from 8,192 cached tokens equals its run on a server with the prompt cache off, drafted and plain.
  • Regression: TensorFold/Qwen3.6-35B-A3B-MLX-4bit-MTP (revision f84b054) gives the same replies on this branch as on main f350cd3 (6 of 6 token hashes).
  • Host: zig build test (5,604 passed, 27 skipped) and zig build test-cuda-host (89/89) pass; zig fmt --check and git diff --check are clean; lean_check.py reports 426 problems, the same as main.

Numbers

The run on record is the recipe's (docs/recipes/qwen3.6-moe.md), the same numbers as the changelog: 256 tokens, greedy unless noted, thinking off.

Code Chat
--no-drafts (tools/bench_openai.py) 66.6 tok/s 66.2 tok/s
MTP drafts, one stream (tools/bench_concurrent.py) 124.7 tok/s 111.8 tok/s
MTP drafts, four streams, aggregate 240.3 tok/s 236.2 tok/s

Cold prefill (a fresh server per size, --prompt-cache-gib 0): 0.38 s to first token at 2,048 tokens, 1.42 s at 8,192, 6.59 s at 32,768 and 15.91 s at 65,536. A re-run of the gate on this branch over main f350cd3 passed the same exactness checks, with decode within 3% of these.

Overlap

atc overlap and git merge-tree against every open pull request: this PR adds no conflicts. #1335 shares cuda_native_moe.zig but already conflicts with main in the same files without this change.

The DFlash2 drafter (#1674) and the 1M YaRN window (#1675) follow as stacked PRs under the same issue.

Follow-up commit fix(qwen3_5): size the MTP loader's scratch for fc's halves takes the review's first point: the scratch now holds the largest matrix the loader stages (hidden × max(hidden, 2·aw, kvw, intermediate)); the size is unchanged for Qwen3.6.

@ashhart

ashhart commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Thank you for this. It's a careful, well-tested piece of work, and serving NVIDIA's checkpoint as downloaded is something we want.

One housekeeping note: the six commits were authored with an email address that isn't your GitHub noreply one, and we only land noreply identities. Your earlier commits here use 106273+laudney@users.noreply.github.com, so we've re-authored our prepared copy of the branch with that address. The code is byte for byte the same, and it will land under your name. If you push again, please use that address so it stays that way.

We're now running the regression half on our GB10: Qwen3.6-35B-A3B MLX 4-bit with MTP against main, at 1 and 4 streams and cold prompts at 2k, 8k and 32k, with replies token-equal. Your receipts cover the new NVFP4 path.

Two small optional things, if you touch it again:

  • In cuda_mtp_bf16.zig, the values scratch buffer is sized as max(2 * attention width * hidden, intermediate * hidden). The fc halves need hidden * hidden, so it would help to include that in the max. It's fine for Qwen3.6, but it would overflow on a model whose attention width is under half its hidden size.
  • The decode numbers differ slightly between the changelog line, the recipe table and the description (for example 240 vs 293 tok/s at four streams, and "40 of 40" vs "8 of 8" concurrent matches). Aligning them to the run you want on record would help.

@laudney

laudney commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Thank you, and thanks for re-authoring the prepared copy. Everything I push from here on uses 106273+laudney@users.noreply.github.com, and a pre-push check on my side now refuses anything else for this fork.

Both points are taken, without rewriting the branch:

  • Scratch size: the follow-up commit fix(qwen3_5): size the MTP loader's scratch for fc's halves (eea97ab) sizes it as hidden × max(hidden, 2·aw, kvw, intermediate), the largest matrix the loader stages. It is the same size for Qwen3.6.
  • Numbers: the description now cites the recipe's run, the same as the changelog (66.6 / 124.7 / 240.3 tok/s, 40 of 40 concurrent replies). The 293 tok/s and "8 of 8" came from a re-run with a different tool and metric (steady-state aggregate, and cells rather than replies), so I left them out.

The two follow-ups under #1656 are open as stacked PRs: #1674 (DFlash2 drafter, one commit of its own) and #1675 (1M-token window with YaRN, nine commits of its own). Each description says which commits to review.

The Qwen3.6 MoE loader will pack gate and up as two parts of one
NVFP4 expert table with the shared expert last, a packer path no
test reached. The device test now checks it byte for byte with
guards, and the decode-sums contract entries serve Qwen's 512 by
2048 shapes while the K32 tile keeps Flash Next's.
The MTP drafter of a ModelOpt checkpoint will quantize its bf16
layer on the host and needs the same staging and packing the MLX
loader runs after its tensor reads. packLinear and packTable now
hold that tail; linear and expertTable call them, unchanged.
nvidia/Qwen3.6-35B-A3B-NVFP4 was refused because the routed experts
read only MLX tables. Admission now checks every expert projection's
effective ModelOpt recipe and keeps the router bf16; the loader packs
each projection of all 257 experts on the GPU into Nvfp4Experts'
tables with the shared expert last; decode rows run the decode
kernel and prompt tiles its decode-sum form, so a pair has the same
bits in any window. Rules ported from ashhart#345 by gilby.
nvidia/Qwen3.6-35B-A3B-NVFP4 ships its MTP layer in bf16 inside its
own shards, while the CUDA drafter reads only mtp-4bit.safetensors.
Without that file, the loader now quantizes the mtp.* tensors at
load with the shared affine-4 quantizer, as Nemotron's ModelOpt
drafter does, and packs the same tables; a drafter-only exception
the user chose, since every drafted token is verified.
The recipe, the README's support table and the changelog now name
NVIDIA's NVFP4 checkpoint on the native CUDA engine: how to serve
it with its own MTP drafter, what runs in which precision, the
drafter-only exception, credit to ashhart#345, and the GB10 receipts for
decode, concurrency, exactness and cold prefill.
capabilities --json listed only mlx-q4g64 for qwen3_5_moe on CUDA,
though the family now reads NVIDIA's MIXED_PRECISION and
single-format NVFP4 exports. The CUDA MoE family names
modelopt-mixed and modelopt-nvfp4 beside it, with a test; HIP's
registry keeps its own list.
The bf16 MTP loader stages each matrix in one f32 scratch buffer,
sized for q with its gate and for an expert, while fc's two halves
are hidden by hidden: a model whose attention width is under half
its hidden size would overrun it. The buffer now holds the largest
matrix the loader stages (fc's halves, q, k and v, o, an expert);
for Qwen3.6 the size is unchanged, as q is still the largest.
@ashhart
ashhart merged commit d80e5f2 into ashhart:main Oct 31, 2026
9 checks passed
@ashhart

ashhart commented Oct 31, 2026

Copy link
Copy Markdown
Owner

Thanks @laudney. This shipped in TensorFold 1.2.0 today, with your credit in the release notes and the changelog: https://github.com/ashhart/TensorFold/releases/tag/v1.2.0

@ashhart

ashhart commented Oct 31, 2026

Copy link
Copy Markdown
Owner

Thank you @laudney. This is in TensorFold 1.2.0, released today: https://github.com/ashhart/TensorFold/releases/tag/v1.2.0

It landed with your commits under your own name, so you're listed as a TensorFold contributor, and it's credited to you in the release notes and the changelog.

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