Skip to content

Qwen3.6-35B-A3B on CUDA: a 1M-token window with static YaRN rope - #1675

Merged
ashhart merged 10 commits into
ashhart:mainfrom
laudney:pr-q36-yarn
Oct 31, 2026
Merged

ashhart merged 10 commits into
ashhart:mainfrom
laudney:pr-q36-yarn

Conversation

@laudney

@laudney laudney commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1656 (3 of 3). Builds on #1674 (DFlash2): review only the last nine commits, from feat(core): transformers' YaRN table for rope users outside flashnext; the first commit is #1674's and drops out once it merges. It needs #1674 because its draft-config rope check extends code that one adds. Rebased onto main after 1.2.0: the qwen27 rope refusal test now sits in config_test.zig, --rope-yarn in cli_gpu_flags.zig (the earlier cli_parallel.zig split is no longer needed), and the YaRN learner refusal sits beside the KV-share messages.

Why

The checkpoint was trained on 262,144 positions, and SGLang's Spark recipe serves NVIDIA's nvidia/Qwen3.6-35B-A3B-NVFP4 at 1,048,576 with static YaRN (factor 4). Serving the same published weights at the same window with the same rope is what makes a long-context comparison like for like, and long-context users can run the checkpoint as downloaded (--rope-yarn 4) or SGLang's staged 1M copy as is, instead of a TensorFold-specific copy.

What

  1. Core (core/rope_yarn.zig): transformers' static YaRN table (_compute_yarn_parameters, beta 32 and 1, truncated ends, torch's fp32) for rope users outside Flash Next; Flash Next keeps its own copy, which its Metal receipts hash.
  2. Refusals: a scaled rope where TensorFold would read it as plain rope (HIP, Metal, the DFlash2 drafters, another scaling type) is refused instead of ignored; so is --slide (the learner) under YaRN.
  3. CUDA kernels: the Qwen and MTP rope kernels take YaRN's attention factor and 32 frequencies; plain rope is its own instantiation, so its bits are as before (contract records updated; the q35 rope's pre-YaRN 13.2/13.3 receipts dropped).
  4. Serve: --rope-yarn 4 on the checkpoint as downloaded, or a config whose rope carries a YaRN block, raises the window to 1,048,576 (--context 0 serves it whole). A block and a flag must name the same factor. Without either, every byte is as before.
  5. Test: the MTP drafter ropes with YaRN on both paths (three rows absorbed at 300,000: each kv head's ring key against float64, then batched against serial).
  6. Docs: a 1M-window section in the Qwen3.6 recipe and a changelog entry.
tensorfold serve nvidia/Qwen3.6-35B-A3B-NVFP4 --drafter nvidia/Qwen3.6-35B-A3B-NVFP4 --rope-yarn 4 --context 1048576

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

The run on record is the recipe's, with the changelog's numbers:

  • 1M window (--parallel 2, 22.2 GiB a stream, so a GB10 admits two): drafted replies equal their "draft": false runs (6 of 6), concurrent ones their solo runs, a resumed second turn its fresh run. A passphrase 5% into a 300,000-token prompt comes back after 173 s of prefill (1,740 tok/s). A 1,048,512-token prompt prefills in 1,546 s (680 tok/s), returns its passphrase, and decodes 64 tokens to the window's last position at 31 tok/s; one token more is refused with the server's context-window error.
  • Plain rope unchanged: with no flag, the same six reply hashes as Qwen3.6-35B-A3B on CUDA: serve NVIDIA's NVFP4 checkpoint as downloaded #1662's gate, byte for byte.
  • Flag and staged config agree: --rope-yarn 4 on the download and SGLang's staged 1M config give the same reply hashes (6 of 6).
  • Device: tf-cuda-test rope-yarn (YaRN rope in tf_q35_rope, tf_q35_rope8 and tf_q36m_norm_rope against float64, to 1M) and the MTP check pass.
  • Host: zig build test 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.

A re-run on this stack passed every check above again (the 300,000-token needle included), without repeating the 26-minute 1M prefill.

Overlap

git merge-tree against every open pull request: most shared files are adjacent additions (a field, an error, a command or a test beside another PR's). The one real overlap is #1632: both change how native/cuda.zig finds a family's context ceiling (this PR's contextCeiling for YaRN, #1632's trained_context); merged, it checks contextCeiling, then trained_context, then modelContext.

@ashhart

ashhart commented Oct 31, 2026

Copy link
Copy Markdown
Owner

Thanks @laudney, both rebases are clean and prepared here on current main with your commits as they are. They're in our gate now, and #1675 (which carries #1674) is queued on a GB10 to check that the unset path stays exact and level on Qwen3.6-35B-A3B. We don't yet have nvidia/Qwen3.6-35B-A3B-NVFP4 or incoai/Qwen3.6-35B-A3B-DFlash2 on our boxes, so the drafted and YaRN paths will rest on your receipts unless we can fetch them; I'll say which before landing.

@ashhart

ashhart commented Oct 31, 2026

Copy link
Copy Markdown
Owner

Device results, @laudney: on a GB10, Qwen3.6-35B-A3B MLX 4-bit with its MTP head (the default --drafter path your #1674 rewires), this PR (which carries #1674) against main: tokens equal on all 20 prompt rows; prompt speed and decode level from 2k to 64k (-0.5% to +0.1%); the sweep within 1% at 1 to 8 streams; the 64k long-context check passes on both sides. Unset, nothing moves, as intended. We still don't have the NVFP4 target or the DFlash2 drafter here, so the drafted and YaRN paths rest on your receipts. Both PRs are queued to land now, #1674 first.

On CUDA the Qwen3.6 MoE target wired --drafter to its MTP layer
alone, though the engine's DFlash2 drafter already reads the
SGLang recipe's incoai/Qwen3.6-35B-A3B-DFlash2. One openDrafter
now serves both families: a directory whose config.json has
dflash_config opens DFlash2, as every directory does under a dense
target, and any other opens the MoE target's MTP layer. Both
openers return one Opened struct whose prepare is optional, which
cuda_drafted.zig now accepts. On a GB10 one build drafts with
either; every drafted reply equals its plain run, and DFlash2
decodes 130.3 tok/s at one stream on a 4.2K-token prompt against
MTP's 98.6.
The Qwen CUDA path needs transformers' static YaRN frequencies for
its 64 rotary dims, which only flashnext computes today, in a file
its Metal accuracy receipts hash by bytes. core.rope_yarn copies the
formula so that file stays as it is; a host test holds the copy to
transformers 5.19.0's own fp32 words for Qwen3.6's config, and a
Flash Next test holds the two copies equal over six configurations.
Several Qwen3.5/3.6 config readers ignored rope scaling, so a YaRN
config such as SGLang's staged 1M copy of Qwen3.6 would load and
run with plain rope: HIP's and loader.zig's config.geometry, the
dense Metal reader, the CUDA geometry's rope_scaling block, and both
DFlash2 parsers. Each now refuses a rope_type or type other than
default and a non-null rope_scaling by name, and config.geometry
gets the trained-window limit parse already had; published configs
load as before, with a test for each refusal.
The Qwen target's rope and the MTP layer's turn each pair by theta's
own frequency, so a YaRN window past 262,144 positions has nowhere
to go. tf_q35_rope, tf_q35_rope8 and tf_q36m_norm_rope now take a
33-float table (the attention factor, then 32 frequencies); a null
table keeps today's expression. The engine holds the table's device
address, the forward passes it, and the MTP's serial and batched
layers share one launch helper. A new FP64 contract (rope-yarn)
covers text and image rows to position 1,048,575 on bf16 and e4m3
caches and the MTP kernel's q and k, and the records follow the
kernels' new register use.
SGLang serves Qwen3.6 at a 1M window from a staged config with YaRN
rope; TensorFold refused such a config and had no way to stretch a
downloaded one. cuda_rope.resolve now reads a YaRN block (in
rope_parameters or the older rope_scaling) within transformers'
default formula, or applies --rope-yarn FACTOR over the config's
trained window, and the CUDA server checks --context against that
ceiling. The engine uploads the table before any forward and again
for the flag, dropping graphs captured with the old one. Every other
CUDA family, HIP's open and follow, Metal and XPU refuse the flag by
name; two --parallel helpers move to cli_parallel.zig so cli.zig
keeps its 600-line limit.
Drafted replies equal their plain runs whatever the drafter's rope,
since the target verifies every draft, so that gate cannot show the
MTP layer missing its YaRN table. The drafter's GPU check now sets
a table on the engine, absorbs three rows at position 300,000 and
holds each ring key to the float64 YaRN rope of the key row its
layer computed, then runs the batched-against-serial check with the
table; it passes on a GB10 within 0.29 of its bound.
Commit 8317b03a merged the plain and YaRN rope into one expression,
and nvcc, contracting with fmad on, then fused plain rope's pair
products differently: three of six served reply hashes changed with
YaRN off. The target's rope and the MTP layer's now branch once per
launch into a plain instantiation that is the old code, and a YaRN
one; on a GB10 every plain reply hash equals main's again, and the
YaRN contracts and the MTP checks still pass. The records follow:
each instantiation keeps its own shared row, 512 bytes more a block.
The recipe now shows how to serve nvidia/Qwen3.6-35B-A3B-NVFP4 at
1,048,576 tokens, by --rope-yarn 4 or a staged YaRN config, what it
costs a stream, what YaRN changes, and the GB10 measurements: exact
drafted, concurrent and resumed replies, a passphrase found at
300,000 and near 1M tokens, and the window's boundary; the
CHANGELOG has the entry.
The YaRN table changed tf_q35_rope and tf_q35_rope8, and their 13.0
records were measured again, but nvcc 13.2's and 13.3's override
entries still carried the old kernels' usage, which the registry
prefers for images those compilers build. Both entries go, so those
compilers' images fall back to the records' pending state until a
13.2 or 13.3 receipt of the new kernels is measured.
Sliding Weights' backward attention ropes with theta's plain
frequencies and no attention factor, so under --rope-yarn or a YaRN
config its gradients would not match the forward pass they train.
The learner's shared admission, cuda_learn_shape.from, which both
the server's --slide and tf-qwen35-learn reach after the engine
knows its rope, now refuses a YaRN rope by name instead of learning
with the wrong one.
@ashhart
ashhart merged commit 389ca13 into ashhart:main Oct 31, 2026
8 of 9 checks passed
@ashhart

ashhart commented Oct 31, 2026

Copy link
Copy Markdown
Owner

Thank you @laudney. 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.

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.

Qwen3.6-35B-A3B on CUDA: serve nvidia/Qwen3.6-35B-A3B-NVFP4 (ModelOpt NVFP4 MoE) as downloaded

2 participants