Skip to content

Flash Next on CUDA: serve NVIDIA's NVFP4 checkpoint on one GB10 or at --tp 2 - #1771

Open
laudney wants to merge 13 commits into
ashhart:mainfrom
laudney:pr-fn-nvfp4
Open

laudney wants to merge 13 commits into
ashhart:mainfrom
laudney:pr-fn-nvfp4

Conversation

@laudney

@laudney laudney commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

The native CUDA build serves nvidia/Qwen3.8-Flash-Next-NVFP4, NVIDIA's own ModelOpt export, as downloaded: its BF16
attention, DeltaNet and shared-expert layers (where the FP4-Local export keeps MXFP8), FP8 n-gram rows, and the MTP
head's FP8 block experts. Serving the publisher's original weights matters for benchmarking and adoption: it is the
checkpoint other engines publish numbers for, and the one users download first. --tp 2 now reaches the CUDA
families with ranks, and two ranks agree on a computed checkpoint digest before they serve.

At --tp 2 the dense and shared layers run whole on each rank, and the routed experts are expert-parallel: each rank
loads 256 whole experts, a small kernel (fn_ep_picks) maps a pass's picks to the rank's own ids and skips the rest,
and the rows a rank skips stay +0, so the existing all-reduce adds x + 0. Replies at --tp 2 are one GPU's bytes.
(Main's intermediate-channel split of each expert added two FP32 halves, which changed 2 of 6 replies against one GPU.)

Commits:

  1. fix(flashnext): keep the ranked vtable static — at TP=2 backend() returned a pointer into its own stack frame.
  2. feat(cuda): serve --tp to the CUDA families with ranks
  3. feat(flashnext): ranks agree on a computed checkpoint digest
  4. feat(flashnext): accept BF16 dense and shared layers in ModelOpt
  5. feat(flashnext): FP8 rows in the n-gram table
  6. feat(flashnext): FP8 block experts in the MTP head (with an f64 device test at the head's shapes)
  7. docs(flashnext): serve NVIDIA's NVFP4 checkpoint at TP=2
  8. refactor(cuda): keep --tp's refusals and test out of cuda.zig (the 600-line rule)
  9. test(contract): gate Flash Next NVFP4's BF16 and FP8 expert kernels
  10. fix(flashnext): keep one GPU's expert sums at --tp 2
  11. docs(changelog): note --tp 2 replies equal one GPU's for Flash Next, and merges of main for CHANGELOG.md

For #1754. TP=2 splits of the dense layers and the prompt kernels follow in their own pull requests, as agreed there.

Receipt

  • Environment: TensorFold 553beebef (served receipts; later commits change only CHANGELOG.md), CUDA 13.4 (nvcc
    13.4.92), two DGX Spark GB10s (sm_121, 128 GB each) on a direct 200 GbE link for --tp 2, one for TP=1;
    nvidia/Qwen3.8-Flash-Next-NVFP4 revision fc694b54; --context 65536 --prompt-cache-gib 0.
  • Tokens equal between one GPU and --tp 2 (fn-exact cases, token_sha): prose t=0 bfdb2a6d0b2a, prose t=0.7
    a80787f5ca38, prose t=1.0 81767b5bb34a, code t=0 c993e964aaab, code t=0.7 c8cb5fcf645a, code t=1.0
    d146bc48932a at both; the mixed round (d98bfa8c2a22, 2084a6db8b67, a4bb1abe239e) and a cancelled stream's
    retry (59b9985f506f) equal at both too.
  • At TP=1 and --tp 2 (tools/bench_concurrent.py --alone --serial, token_sha against "draft": false): drafted
    equals "draft": false in 6 of 6, every concurrent cell equals its solo run, a mixed round of drafted and plain
    streams equals its solo runs, and a cancelled stream's next request equals a fresh one.
  • Decode speed (tools/bench_openai.py, 256 tokens, greedy, median of 3): --tp 2 46.4 tok/s (fibonacci) and 34.4
    (chat); TP=1 49.5 and 36.3. Before: upstream refuses this checkpoint.
  • Prompt speed (tools/bench_cache.py --require-cold, a 4,189-token prompt, 0 cached tokens): --tp 2 4.69 s and
    4.63 s, TP=1 2.81 s. Before: refused.
  • Memory: each rank holds 45.31 GiB of model, a stream at a 65,536-token window 2.18 GiB.
  • Kernel contract (nvcc 13.0): SUMMARY 531 kernels: 531 pass gates 1-4, 0 fail, 6 known findings; 346 controls: 346 red as planted, 0 missed; 181 limit problems at 99ade8e2b. The 181 limit problems are in launches this pull
    request does not change, with main's sources and records: the Qwen3.5 attention and compaction launches
    (qwen35, qwen35_kv8, qwen35_keep), the Qwen4 launches with no compiled usage recorded (qwen4_*), and the two
    K32 expert tile launches main keeps pending (nvfp4_experts_tile). The new fixtures alone at 553beebef
    (tf-cuda-test flashnext-modelopt-contract): SUMMARY 5 kernels: 5 pass gates 1-4, 0 fail; 3 controls: 3 red as planted, 0 missed — the qmmf BF16 lane (bf16 and fp32 out) at the dense and shared shapes, fp8_experts' decode
    and prompt downs at the MTP head's 2,560 x 640, and fn_ep_picks, with 1.5x-scale and off-by-one controls;
    fn_ep_picks' nvcc 13.0 records pass zig build test-kernel-contract.
  • Tests: zig build test-cuda-host passes; tf-cuda-test flashnext-nvfp4-tp checks two expert-parallel ranks'
    summed outputs are byte-equal to one GPU's.
  • Not run: Metal and HIP (no change there); other CUDA GPUs than the GB10 (sm_121); the FP4-Local checkpoint, whose
    routed experts share this path.

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.
  • No precision traded for speed; --tp 2 keeps one GPU's sums.
  • A new or changed GPU kernel is registered with the kernel contract; the SUMMARY lines are above.
  • Prompt processing is no slower than the last release (the checkpoint did not load before).
  • Platforms: CUDA on GB10 at TP=1 and TP=2; not Metal, HIP or other CUDA GPUs.
  • 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.

Merge-readiness update (2026-11-01)

Rebased onto 9e8c368b0 and retained both changelog sections. The only other cleanup is three contract-comment lint warnings. The FlashNext CUDA paths, CUDA runtime and CUDA kernel sources remain byte-identical to 609d4e5e2; the existing device receipts keep that source identity. Lean is 424 inherited findings and zero new. The shared CUDA --tp change remains its own commit, now f89390b18. CI will run again on the updated head.

@laudney

laudney commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

@ashhart could you prioritise this one for review and merge? Serving Qwen3.8-Flash-Next from NVIDIA's own NVFP4 export gives the project a leg up on early Qwen4 support: the loader paths it adds (BF16 dense and shared layers in ModelOpt, FP8 n-gram rows, FP8 block experts in the MTP head), --tp for the CUDA families with ranks and the computed checkpoint digest are the pieces a Qwen4 NVFP4 checkpoint is likely to need on day one. The two follow-ups (the TP=2 split and the prompt kernels) stack on this, so landing it first unblocks both.

@laudney

laudney commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Two commits on top, no force-push:

  • 9c7c77262 keeps zig/src/native/cuda.zig under the 600-line rule: Fish's and Qwen4's --tp refusal moves into refusals.backend(), and the --tp admission test moves beside the refusals' rank tests. The file is 598 lines on current main.
  • 42eb861a4 merges current main for the CHANGELOG.md entries.

Working on the receipts asked for on #1754 next: tokens equal between one GPU and --tp 2, and contract coverage with planted controls for the BF16 dense, FP8 n-gram and FP8 MTP-expert paths. I'll add them here.

@laudney

laudney commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

The expert-parallel fix is in (553beebef): each rank loads 256 whole routed experts, fn_ep_picks maps a pass's picks to the rank's own ids and skips the rest, and the existing all-reduce adds x + 0. With it, --tp 2 replies are one GPU's bytes:

case (fn-exact) one GPU token_sha --tp 2 token_sha
prose t=0 bfdb2a6d0b2a bfdb2a6d0b2a
prose t=0.7 seed 11 a80787f5ca38 a80787f5ca38 (was b2a2d639128a)
prose t=1.0 seed 23 81767b5bb34a 81767b5bb34a
code t=0 c993e964aaab c993e964aaab (was d8612a55238a)
code t=0.7 seed 11 c8cb5fcf645a c8cb5fcf645a
code t=1.0 seed 23 d146bc48932a d146bc48932a

The mixed round (d98bfa8c2a22, 2084a6db8b67, a4bb1abe239e) and the cancelled stream's retry (59b9985f506f) are equal between one GPU and --tp 2 too. At both, drafted = "draft": false in 6 of 6 and every bench_concurrent --alone --serial cell equals its solo run.

Kernel coverage (99ade8e2b, 553beebef): tf-cuda-test flashnext-modelopt-contract gives SUMMARY 5 kernels: 5 pass gates 1-4, 0 fail; 3 controls: 3 red as planted, 0 missed for the qmmf BF16 lane (bf16 and fp32 out) at the dense and shared shapes, fp8_experts' decode and prompt downs at the MTP head's 2,560 x 640, and fn_ep_picks, each with a planted control (1.5x scales, an off-by-one first expert). flashnext-nvfp4-tp checks two expert-parallel ranks' summed outputs are byte-equal to one GPU's. The full contract SUMMARY and before/after speed at --tp 2 follow; then I'll merge main for the CHANGELOG.md entries.

@laudney

laudney commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor Author

@ashhart the tokens-equal receipt found a difference I want to flag before fixing it. At this branch's tip (42eb861a4), --tp 2 and one GPU agree on drafted = plain (6 of 6), concurrent = solo, the mixed round and cancellation, but 2 of the 6 replies differ between one GPU and --tp 2 (prose at t=0.7 and code at t=0).

The dense and shared layers run whole on each rank here, so the difference comes from main's routed experts: cuda_modelopt_experts.zig splits each NVFP4 expert by intermediate channels across the ranks, and collective.sum then adds the two ranks' FP32 halves of every pair's down row, an order one GPU never uses.

The fix I'd like to add here is expert parallel for the routed experts: each rank loads 256 whole experts (all 640 channels, so the same bytes a rank as now), a small kernel maps each rank's picks to its own experts and skips the rest, and the rows a rank skips stay exact zeros, so the existing all-reduce adds x + 0 and every pair keeps one GPU's sums. No expert kernel changes; the new kernel gets its contract fixture and a planted control.

@ashhart

ashhart commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

Thanks @laudney. Our plan for the three: #1771 lands on its own. The expert-parallel fix (whole experts per rank, so TP=2 gives one GPU's bytes) is exactly the shape we want, and the contract run is clean. The only gate findings are three lean ones from the contract commit (the //! comment in contract_family_sets.zig and two comments past 120 columns in contract_flashnext_modelopt.zig); we'll fix those ourselves on landing unless you push first. We don't have NVIDIA's NVFP4 export on our boxes, so your receipts stand for it; our GB10 run checks that the FP4-Local checkpoint we serve today is unchanged against main, and that goes first. #1784 still changes TP=2 sums by default, so as on #1754 it needs the split by output columns over gathered inputs (or the dense layers left whole) before it can land. #1792 follows #1784; its GB10 receipts were a clean gain.

@ashhart

ashhart commented Nov 1, 2026

Copy link
Copy Markdown
Owner

Thanks @laudney. We ran this branch against its main base on one GB10, on our Qwen3.8 Flash Next FP4-Local checkpoint (we don't have NVIDIA's NVFP4 export, so this run covers no regression for the formats we already serve, not the new checkpoint itself). Base, new, new, base:

  • Prompts: +0.2% to +3.4% at 2K to 64K tokens, and no prompt size slower beyond run noise (the worst cell is -0.3% at 2K).
  • Decode after a prompt: -1.0% to +2.6%, within the runs' own spread.
  • Decode sweeps at 1 to 8 streams, chat and code, greedy and sampled: +0.1% to +0.7%, none slower.
  • Prompt tokens: identical in all 20 rows across runs.

That reads GREEN. Main is frozen for the 1.2.1 release right now, so this lands right after it, on its own as planned.

@ashhart

ashhart commented Nov 1, 2026

Copy link
Copy Markdown
Owner

Thanks @laudney, and congratulations: 1.2.1 just shipped, so this lands next. We read it through: at one GPU the FP4-Local and MLX paths are the same calls as before. The routed
experts, the MoE pass, the n-gram rows and config all match, and our GB10 run on FP4-Local is green, with nothing
slower and identical prompt tokens. Two things before we land it:

  1. --tp 4 or 8, or --tp 2 on the MLX checkpoint or captured kernels, now fails with a bare BadRank or
    UnsupportedQuantization. Could you add them to cuda_lanes_explain.zig in words?
  2. Could the changelog line take (#1771, for #1754). Thanks to @laudney. and Qwen3.8-Flash-Next's spelling? We
    can adjust its wording at landing.
    We couldn't run NVIDIA's own export here, so its receipts are yours.

laudney added a commit to laudney/TensorFold that referenced this pull request Nov 2, 2026
At load, --tp 4 or 8 failed with a bare BadRank, and --tp 2 on an
MLX checkpoint or on captured kernels with a bare
UnsupportedQuantization, an error other one-GPU load paths also
raise. Each --tp refusal now has its own error, and Flash Next sets
explains_open so cuda.zig asks explain() at load: with no engine it
words only load refusals, so a request's words (out-of-memory, a
long prompt) never describe a failed load. The CHANGELOG entry
spells Qwen3.8-Flash-Next and credits ashhart#1771 for ashhart#1754.
@laudney

laudney commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

@ashhart both are in, as new commits on top (no force-push):

  1. be2534762 words the --tp load refusals. Each one now has its own error instead of the bare BadRank / UnsupportedQuantization (the latter is also raised by one-GPU load paths, so its words could not name --tp):

    • --tp 4 or 8: "Flash Next on CUDA runs on one GPU or at --tp 2 (two ranks): serve with --tp 1 or --tp 2"
    • --tp 2 on the MLX checkpoint: "--tp 2 splits Flash Next's ModelOpt checkpoints only; serve this MLX checkpoint on one GPU, without --tp"
    • --tp 2 on captured kernels: "--tp 2 runs Flash Next's own glue, not a captured set: set TENSORFOLD_CUDA_KERNELS=native, or serve on one GPU"

    Flash Next did not set explains_open, so cuda.zig never asked cuda_lanes_explain.zig at load. It now does, as Qwen3.5 does: with no engine, explain gives only load refusals (the three above, plus the existing --slide, KV-pages and checkpoint ones), so a request's words such as "retry once another request ends" never describe a failed load. One note on the existing --slide line: it says "serve it without TENSORFOLD_CUDA_KERNELS", but aot.pick loads a release's bundled share/tensorfold/cuda/sm<N> set when the variable is unset, so =native is the reliable advice there too. I left it unchanged here.

  2. The CHANGELOG line now reads Qwen3.8-Flash-Next and ends (#1771, for #1754). Thanks to @laudney.

c85ab7524 and 91ae48693 merge main (1.2.1, then #1853). One conflict was more than the CHANGELOG: main's side-stream shared expert (cuda_batch_side.zig) changed the inline MoE block that this branch had moved into cuda_moe_native.run. The port forks it after the y clear and joins it before the rank sum, the same order as main. fork runs only at one GPU, so --tp 2 keeps the shared expert on rank 0 as before. Its contract set and this branch's ModelOpt set both stay in contract_family_sets.zig.

Checks at 91ae48693's code: zig build test-cuda-host compile-cuda-tests pass, plus a new host test for the load words; lean_check.py gives 424 problems, the same 424 as main (the three you found are fixed). I did not repeat the GPU runs for the side-stream port.

@laudney

laudney commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

@ashhart GitHub now shows #1771 as conflicting with main (4de3944ad). Three files conflict, after today's #1739, #1873 and the contract family-set change:

  • CHANGELOG.md
  • zig/src/families/flashnext/cuda_batch_state.zig
  • zig/tests/cuda/contract_family_sets.zig

Do you want us to rebase the branch, or will you resolve these when you land it? If you want us to update it, should we rebase (force-push, so the SHAs in this thread change) or merge main in on top? Nothing else has changed since my comment this morning.

@ashhart

ashhart commented Nov 2, 2026

Copy link
Copy Markdown
Owner

Thanks @laudney. Please rebase onto current main, and a force-push is fine. A linear branch is easier to gate than a merge of main, and we track the PR, not its SHAs. CHANGELOG.md conflicts we resolve ourselves at landing, so keep your branch's line and don't fight it. The two code conflicts (cuda_batch_state.zig after #1739, and contract_family_sets.zig after the EXL3 contract set) are yours to resolve. Once it's rebased, we'll check be2534762's refusal wording and gate it.

backend() built the ranked table with a runtime call, so at TP=2 it
returned a pointer into its own stack frame and the first prompt
chunk on two GB10s jumped to address 0x25. The table is now built at
comptime like the scoring table, and a test reads it after a real
call and an overwritten stack, so a table in that frame fails it.
Flash Next's two-rank path was built and consumed --tp, but the CUDA
server refused the flag. It now serves --tp, Fish, Qwen4 and LFM2,
which open outside family admission, refuse it with the same words
family() uses, and --idle-unload is refused with --tp on CUDA, since
closing rank 0 ends its followers and a reload would have none.
Two Flash Next ranks only started when the operator set
TENSORFOLD_CHECKPOINT_SHA256, which nothing documented. Each rank now
sends the cached full digest GLM's ranks use, and the variable still
overrides it. A cold digest reads every byte, so the link and its
hello wait 30 minutes for the peer when the digest is computed.
NVIDIA's NVFP4 Flash Next checkpoint keeps its attention, DeltaNet
and shared-expert projections in BF16, so they are absent from
quantized_layers, and the check refused every one of them. Each
projection may now be MXFP8/32 or unlisted, and the parts the loader
stitches into one projection must all be one or the other.
NVIDIA's NVFP4 Flash Next checkpoint stores its n-gram table as E4M3
rows with one BF16 scale for the whole table, which the loader
refused. A third row format reads them as the reference does, each
code times the scale rounded once to BF16, through the mapped
gather, the positional reader, the distinct gather and warm-ahead.
The MTP head of NVIDIA's NVFP4 Flash Next checkpoint keeps its routed
experts in E4M3 with BF16 128x128 block scales, which only an NVFP4
loader read. They now pack for the existing FP8 expert kernels, the
MoE pass dispatches them from a file of their own, and the head's
memory estimate counts them at a byte a weight with their scales; a
device test runs that call at the head's 512 experts, 640 by 2560,
against an f64 reference, as reply equality cannot catch a wrong
draft that verification rejects.
The recipe gains the two-rank command for
nvidia/Qwen3.8-Flash-Next-NVFP4 with what each rank needs: the same
executable, NCCL 2.30 or later, --prompt-cache-gib 0 with drafts and
the digest's first-start cost, and states that concurrent streams do
not yet share a verify pass; the changelog records the branch.
cuda.zig went past the 600-line rule with main's latest lines, so
Fish's and Qwen4's --tp refusal moves into refusals.backend(), which
open() already calls before either family opens, and the test that
the server serves --tp and admits Flash Next's two ranks moves beside
the refusals' own rank tests; open() reads as it does on main.
The checkpoint's BF16 dense and shared layers run main's qmmf BF16
lane and its MTP head's routed experts run main's fp8_experts
kernels, which the contract had no gates 1-4 fixture for at Flash
Next's shapes. A new set runs the lane's BF16-weight product with
bf16 and fp32 outputs at the dense and shared shapes (decode rows),
and the FP8 expert down at decode and prompt pair counts against
the float64 reference, each with a planted 1.5x scale control; the
other experts hold NaN codes, so a misread expert goes red. It joins
the full contract and runs alone as flashnext-modelopt-contract.
At --tp 2 two of six replies differed from one GPU because each
routed NVFP4 expert was cut by intermediate channels and the ranks'
FP32 halves added in the all-reduce. Each rank now loads 256 whole
experts, fn_ep_picks maps the pass's picks to the rank's own ids
with every other id (and the shared slot) skipped, and the rows a
rank skips stay +0, so the all-reduce adds x + 0 and every pair
keeps one GPU's sums. One GPU and the expert kernels are unchanged;
the TP device test now checks the two ranks' sum equals one GPU's
bytes, and fn_ep_picks has its contract fixture, control and nvcc
13.0 records.
The NVFP4 checkpoint's entry now says that at --tp 2 each rank owns
half the routed experts whole, so replies are one GPU's bytes, which
the expert-parallel fix on this branch made true.
PR1 adds three comment warnings against the current upstream base.
Keep the contract descriptions on one line within 120 columns so
Lean accepts the branch without changing test or runtime behavior.
At load, --tp 4 or 8 failed with a bare BadRank, and --tp 2 on an
MLX checkpoint or on captured kernels with a bare
UnsupportedQuantization, an error other one-GPU load paths also
raise. Each --tp refusal now has its own error, and Flash Next sets
explains_open so cuda.zig asks explain() at load: with no engine it
words only load refusals, so a request's words (out-of-memory, a
long prompt) never describe a failed load. The CHANGELOG entry
spells Qwen3.8-Flash-Next and credits ashhart#1771 for ashhart#1754.
@laudney

laudney commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

@ashhart Rebased onto current main (933b969fa) and force-pushed. The branch is now 13 linear commits with no merges, and GitHub shows it as mergeable.

Before Now
Head 91ae48693 db7e9f547
--tp refusal wording be2534762 db7e9f547
Side-stream shared expert in cuda_moe_native.run merge c85ab7524 399d53515 (FP8 block experts in the MTP head)

The two code conflicts:

  • cuda_batch_state.zig: Scratch's size keeps both new buffers, flashnext cuda: a shared round's attention in one launch a kernel #1739's lanes_bytes and this PR's picks_ep (pairs * 4). Both are s.added in the layout.
  • contract_family_sets.zig: the EXL3 set from main stays, and the ModelOpt set goes back in its place, after MOSS-Image and before grouped affine-4. Its errdefer and deinit follow the same order.

The side-stream port is the same code as in the old merge, now in the commit that first moves the native branch into cuda_moe_native.run: shared.fork after y is cleared, and shared.join after the routed experts. At --tp 2 and on prompt fills, fork returns null and rank 0 runs the shared expert inline, as before. CHANGELOG keeps this branch's line at the top of Unreleased.

Checks on db7e9f547: zig fmt --check on the changed files; the lean check against main (0 new problems); and zig build compile-cuda-tests test-cuda-host passes. I did not rerun anything on a GPU after the rebase. The two code conflicts touch only a size sum and the contract set list, and the side-stream port is unchanged from the old merge.

@ashhart

ashhart commented Nov 2, 2026

Copy link
Copy Markdown
Owner

Thanks @laudney for the rebase. It's gated and queued to land. The rebase had left three copies of your changelog line, so we kept one, the fullest, with the thanks on it.

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