Skip to content

Flash Next CUDA: NVFP4 experts on prompt tiles with the decode form's sums, bit for bit - #1143

Closed
jschmied wants to merge 2 commits into
ashhart:mainfrom
jschmied:fn-nvfp4-prompt-tiles
Closed

jschmied wants to merge 2 commits into
ashhart:mainfrom
jschmied:fn-nvfp4-prompt-tiles

Conversation

@jschmied

@jschmied jschmied commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

On a ModelOpt NVFP4 checkpoint (nvidia/Qwen3.8-Flash-Next-FP4-Local, local-inference-lab's and Mia-AiLab's exports)
the routed experts of a prompt chunk ran the decode kernel: nvfp4_expert_kernel on plans of 16 pairs an item, so a
1,536-row chunk (about 30 pairs an expert) read each expert's weights two to three times, through a kernel without
shared-memory staging. The prompt tiles nvfp4_experts.cu already has (tf_nvfp4_prompt_*, Nemotron's) sum
differently (codes times scales in bf16, one fp32 sum over K) and have no fp32 down, so they could not stand in.

  • prompt_tiles gets a FACT flag. With it each 16-input block is one mma of the raw codes from zero and then
    fma(p, its e4m3 scale, acc), blocks in ascending K, then the global scale and the epilogue: the decode form's
    arithmetic in its order, so every row's bits are the decode form's at any row count. Without it the code is as
    before; the 55 existing functions' SASS is identical to main's on sm_80, 86, 89, 120 and 121.
  • Two entries: tf_nvfp4_fact_swiglu (SwiGLU gate-up to bf16, 4 warps) and tf_nvfp4_fact_down_f32 (fp32 slot rows,
    8 warps), on plans of promptTile(.nvfp4) = 64 pairs an item; Nvfp4Experts.runFact launches them.
  • cuda_batch.zig's ModelOpt expert path takes them from 64 rows a pass (TF_FLASHNEXT_FACT_ROWS, 0 for never);
    below it, decode rounds and verify windows, nothing changes. Since the bits are the same either way, the threshold
    only sets speed.

Overlap: Mirrdhyn's #1046 does this for the MLX affine experts (flashnext_fexp.cu, factored g32 tiles) and the
format-independent prompt kernels; it does not touch the ModelOpt NVFP4 experts. This branch is off main and stacks
on nothing of #1046; the two touch different expert formats, and cuda_batch.zig's moeBlockKeep in different
branches of its if (lw.native_experts).

Receipt

  • Environment: TensorFold main d1018b31 plus this branch; one DGX Spark (GB10, sm_121, CUDA 13.0, nvcc 13.0.88);
    checkpoint nvidia/Qwen3.8-Flash-Next-FP4-Local@b9ce18a6 (33/33 files against the publisher's hashes).

  • Exactness:

    • tf-cuda-test nvfp4-fact: random NVFP4 experts (dims 256, width 160, 6 experts), gate-up and fp32 down from the
      fact tiles on 64-pair plans byte-equal to the decode form on 16-pair plans at 1, 7, 17, 22, 64, 65, 130 and 300
      rows; each output within its float64 bound (worst 0.47 of it for gate-up, 0.01 for down). Control: the existing
      scaled-code prompt tile on the same plan parts from the decode form's bytes (red).
    • Served, greedy, against main on the same checkpoint and flags: every reply byte-identical (three chats, three
      ~8k agent contexts, one 30,922-token context; 64-96 tokens each).
  • Prompt speed (cold, --no-drafts --parallel 4 --context 40960, --prompt-cache-gib 0, time to first token):

    prompt main this
    7,459 tokens 6.09 s 5.30 s 0.87x
    7,938 tokens 6.51 s 5.76 s 0.88x
    8,222 tokens 6.96 s 6.13 s 0.88x
    30,922 tokens 25.74 s 22.60 s 0.88x

    Per-op GPU time of a 1,536-row chunk (cudaEvents around each op class, a local measurement patch): routed experts
    480 ms on main, 330 ms here (every expert's 2.76 MB of weights read once an item; 68 GB a chunk, a 250 ms floor at
    the GB10's bandwidth). With TF_FLASHNEXT_CHUNK=4096 the weights are read once per 4,096 rows: 8k prompts 4.73-5.39 s,
    30,922 tokens 19.93 s, replies again identical (the chunk size is not changed here).

  • Decode speed: unchanged (23.6 / 23.6 tok/s code / chat at one stream, 65.6 / 64.9 aggregate at four, main 23.6 /
    23.7 and 65.6 / 65.1); decode rows never reach 64.

  • Kernel contract, tf-cuda-test kernel-contract (built -Dnvcc, ReleaseFast) on the GB10: SUMMARY 87 kernels: 87 pass gates 1-4, 0 fail, 0 known findings; 8 controls: 8 red as planted, 0 missed; 1533 limit problems (this run less the 2 entries and the control is 85 kernels and 7 controls; the 1,533 limit problems also appear on a main-based run on this box (another branch, same count), none in the two new launches, which pass live on sm_121)

    • 2 new entries (zig/tests/cuda/contract_nvfp4_fact.zig), each a replacement of the decode instance it stands for
      (nvfp4_expert_kernel<2, 2, 4>, <1, 0, 4>): gate 3 bit-equal at Flash Next's shapes (2,560 dims, 640 wide)
      and the tile edges; gate 1 by the stated rule (the decode form's bytes with NaN and infinite global scales, a NaN
      block scale, NaN and infinite inputs, some output non-finite); gate 4 on integer inputs whose partial sums reach
      about 2^20; SwiGLU states sums.bf16 = 30: a bf16 sum that collapses moves the output by about itself, and the epilogue's three bf16 roundings already allow 3u of it, so at most 85x is provable (measured: fp16 misses 1,192,211x, bf16 36x; the fp32 down: 272,629,752x and 1,962,934,214x).
    • Control: the scaled-code prompt tile registered as the decode gate-up's replacement, red at gate 3.
    • Gate 5: 2 launches recorded from nvcc 13.0.88 cubins for sm_80, 86, 89, 120a and 121a; live on sm_121 both match.
      The run's 1,533 limit problems are main's (the offline audit of every recorded launch on this nvcc).
    • tf_nvfp4_fact_swiglu takes 255 registers and spills 116 bytes on sm_121 (the existing SwiGLU prompt tile none).
  • Tests run: zig build test --summary all (built -Dnvcc): 211/211 steps succeeded (the summary counts 1,462 of 1,465 tests passed, 3 skipped); tf-cuda-test nvfp4-fact and kernel-contract; zig fmt --check clean.

  • Not run, and why: sm_80/86/89/120 only through their compiles (gate 5); Metal (no Metal change).

Gotchas

Questions

These came up while measuring and are your call, not part of this change:

  1. TF_FLASHNEXT_CHUNK=4096 instead of the default 1,536 rows takes another 11-12 % off prompts on this checkpoint (numbers above), with identical replies. The cost is about 2.3 GiB more and a longer pause for other streams' decode per chunk (about 3 s instead of 1.1 s). Should the default move, or stay an option?
  2. TF_FLASHNEXT_DRAFT_HEAD=affine4 is 10-13 % faster drafted at one stream on this checkpoint (code and chat), outputs equal. Should it become the default for ModelOpt checkpoints?
  3. Would an opt-in --precision checkpoint for Flash Next be welcome? Withdrawn: I built it locally (FP4xFP4 experts under the checkpoint's input scales), and it gave no speed. At these chunk sizes the experts are bound by streaming their weights, so FP4 math saves nothing, and the extra quantize passes cost a little. It also couldn't be quality-gated, because the Flash Next backend returns no logprobs.

Checklist

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off.
  • Drafted output equals "draft": false (8/8), concurrent equals solo (8/8), and a resumed two-turn prompt equals a fresh one (7,147 of 7,184 tokens restored), each equal to main's output (MTP drafts on, --prompt-cache-gib 4; measured at 074e0ec3, before the later lean fix, which only folds comments and moves code).
  • No precision traded for speed. The bits do not change.
  • A new or changed GPU kernel is registered with the kernel contract; the SUMMARY line is in the receipt.
  • Prompt processing is no slower than the last release (0.87-0.88x of main).
  • The description says which platforms I ran: CUDA on GB10 (sm_121) live; sm_80/86/89/120 through their compiles only; no Metal change.
  • New tests fail before the change, pass after it, and skip cleanly without their dependency (nvfp4-fact's control is red on the old tile).
  • 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.

… sums, bit for bit

A ModelOpt NVFP4 checkpoint's routed experts ran the decode kernel on prompt chunks: plans of
16 pairs an item, so a 1,536-row chunk read each expert's weights two to three times.

prompt_tiles gets a FACT flag: each 16-input block is one mma of the raw codes from zero, then
fma(p, its e4m3 scale, acc), blocks in ascending K, then the global scale and the epilogue, the
decode form's arithmetic in its order. Two entries, tf_nvfp4_fact_swiglu and
tf_nvfp4_fact_down_f32, on 64-pair plans (Nvfp4Experts.runFact); the ModelOpt expert path takes
them from 64 rows a pass (TF_FLASHNEXT_FACT_ROWS, 0 for never). Without the flag the template is
unchanged: the 55 existing nvfp4_experts functions' SASS equals main's on sm_80/86/89/120/121.

tf-cuda-test nvfp4-fact: bytes equal to the decode form at 1 to 300 rows, FP64-bounded, the
scaled-code prompt tile as a red control. Kernel contract: both entries replace the decode
instances (gate 3 bit-equal), gate 4 on integer sums, the scaled-code tile as a control red at
gate 3, gate 5 records from nvcc 13.0.88.
…ch.zig back to 600 lines

The lean gate's new findings on this branch: multi-line comments folded to one line within 120 columns,
experts.zig's runFact moved unchanged to experts_fact.zig (re-exported as Nvfp4Experts.runFact), and
factRows' cache as a file-level optional. nvfp4_experts.cu changes in comments only (sm_121a SASS
identical); lean_new.py: 0 new.
@ashhart

ashhart commented Oct 22, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied for the NVFP4 expert prompt tiles that keep the decode form's sums, and for registering both entries with a scaled-code control that has to fail at gate 3. We are carrying both commits onto current main ourselves, kept as yours, and a separate commit of ours will move the new dispatch policy (factRows() at zig/src/families/flashnext/cuda_batch.zig:89 and the tile choice at :450) into a small helper, since main's copy has grown and the merged file would reach 611 lines, and rename the new fact_k and facts in zig/tests/cuda/contract_tests.zig (:23 and :249), which collide with main's own folded-affine contract. Then it goes to the GB10 kernel contract, served exactness and timing runs, 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.

@jschmied

Copy link
Copy Markdown
Contributor Author

A heads-up, since you asked to be told so we don't collide: we are now working on a follow-up on top of these prompt tiles, not on this PR itself. Please land #1143 as you planned; we won't push to its branch. The follow-up is the down projection at large chunks (at 4,096 rows it is the slow half, with K = 640 and two stages), kept bit-identical to the decode form, and then a separately measured opt-in FP4 x FP4 path for --precision checkpoint. It will come as its own PR, rebased onto your carried version once it is on main.

@jschmied

Copy link
Copy Markdown
Contributor Author

Data for the chunk-size question (question 1 in the description), measured on one GB10 with main @ da275a360 and nvidia/Qwen3.8-Flash-Next-FP4-Local @ b9ce18a6. Eight arms (each TF_FLASHNEXT_CHUNK size, then the order reversed), MTP on, prompt cache on.

Bits do not depend on the chunk. All 272 token-hash comparisons are identical across 1024, 1536, 4096 and 8192. That covers greedy and seeded, c=1 and c=4, and cold, warm and resumed requests at about 10k and 38k tokens. The cuts sit on the 128-row grid and the kernels are row-invariant. So the size is a free knob as far as exactness goes.

chunk cold TTFT ~10k cold TTFT ~38k model + prompt scratch longest decoder pause, c=4 behind a cold 38k prompt
1024 8.49 / 8.29 s 32.92 / 32.53 s 72.80 GiB 1.10-1.15 s
1536 (default) 7.88 / 8.10 s 30.84 / 30.93 s 73.25 GiB 1.40-1.42 s
4096 7.32 / 7.29 s 28.42 / 28.25 s 75.50 GiB 3.17-3.21 s
8192 7.28 / 7.19 s 27.87 / 27.64 s 79.10 GiB 6.29-6.35 s
  • 4096 is the throughput point: -8.5 % cold TTFT against 1536 for +2.25 GiB of scratch (about 0.88 MiB a row). 8192 adds only 1-2 % more, for +3.6 GiB and twice the pause.
  • The longest pause a decoding stream sees is about one chunk's time, since decoders get one round a chunk.
  • The same knob on the EXL3 path in [Draft] EXL3 Flash Next on CUDA: TP=2, BF16 paging, cache reuse, graphs #1113 (one GPU, all experts resident): 1024 -> 4096 cut warm TTFT 11-24 % for +2.2 GiB.

A suggestion, if you want one: choose the size from state rather than a constant tuned on one machine. Use the largest size that fits a share of free memory at load (4096 here) when no stream is decoding, and 1024-1536 while streams decode. Since bits don't depend on the chunk, that choice can't break concurrent == solo or resumed == fresh. TF_FLASHNEXT_CHUNK would stay as the override. The prefill share in #1188 caps how much time prompt work takes from decoders, but not the length of a single pause, so the two fit together.

@jschmied

Copy link
Copy Markdown
Contributor Author

An addendum to the chunk data above: those numbers were measured on main without this PR's tiles. This is the same sweep with the two #1143 commits cherry-picked onto main @ da275a360. Same GB10 and checkpoint, --no-drafts --parallel 4, cold TTFT, two runs per arm.

chunk main main + #1143 tiles
1536 (default), ~10k 6.91 / 6.91 s 6.27 / 6.28 s (-9 %)
1536 (default), ~38k 28.70 / 28.62 s 25.92 / 25.91 s (-10 %)
4096, ~10k 6.51 / 6.38 s 5.59 / 5.49 s
4096, ~38k 26.99 / 27.37 s 22.66 / 22.52 s
8192, ~10k 6.36 / 6.26 s 5.40 / 5.39 s
8192, ~38k 26.08 / 25.84 s 21.97 / 22.18 s
  • With the tiles, chunk 4096 vs today's default 1536 is -11 to -13 %, so the two together are about -20 % against main at the default (6.91 -> 5.5 s, 28.7 -> 22.6 s). 8192 adds only 1-3 % more.
  • Exactness: identical output hashes across main, main + tiles, and chunks 1536 / 4096 / 8192. With MTP drafts and the prompt cache on, drafted == plain, concurrent == solo and resumed == fresh all hold. Decode is unchanged (c=1 21.9-22.9 tok/s, c=4 aggregate 54.6-56.1 in every arm).
  • The tiles are already close to the floor: about 1.0-1.25x the time to stream one layer's experts at the measured ~235 GB/s. A three-stage down projection on top gained only 0.3-1 %, inside run spread, so the follow-up I mentioned above won't come as a PR.

ashhart added a commit that referenced this pull request Oct 25, 2026
… contract names that do not collide with main's

- factRows() and the prompt-tile choice move from cuda_batch.zig to cuda_nvfp4_tile.zig (size() and run()), unchanged in what they pick, since main's cuda_batch.zig has grown and the merge reached 611 lines; it is 600 now.
- contract_tests.zig: the new fact_k and facts become nvfp4_fact_k and nvfp4_facts, since main's folded-affine contract already declares both and the runner did not compile.
@ashhart

ashhart commented Oct 25, 2026

Copy link
Copy Markdown
Owner

Landed on main in 93a215b..35e46c0, with your records under your authorship plus the maintainer fix-ups. Thank you. Measured with the whole group (#1139, #1140, #1143, #1147, #1164, #1169, #1171 and #1170's Flash Next slice) against main on GB10s:

  • Qwen3.8 Flash Next FP4-Local: tokens identical (0 of 20 rows differ). Prefill +23.7% to +28.9% from 2k to 64k (16k: 1356.6 → 1748.4 tok/s). Decode +1.0% to +2.2% at 1, 2, 4 and 8 streams.
  • Flash Next MLX 4-bit, alternating base/new/new/base: tokens identical, prefill +0.4% to +0.8%, decode within noise.

- Synthema, Ash's AI Assistant.

@ashhart ashhart closed this Oct 25, 2026
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