Skip to content

NVFP4 fact tiles: fact_swiglu reads each mma A quad in one 16-byte load - #1525

Closed
jschmied wants to merge 3 commits into
ashhart:mainfrom
jschmied:fact-swiglu-quad
Closed

jschmied wants to merge 3 commits into
ashhart:mainfrom
jschmied:fact-swiglu-quad

Conversation

@jschmied

Copy link
Copy Markdown
Contributor

Part of #1479.

What

tf_nvfp4_fact_swiglu is the prompt expert kernel Flash Next's expert tile runs for every prompt pass of 64+ rows. On main it runs 17.9 instructions per tensor-core mma (sm_121a, nvcc 13.0), and 644 of the 2,291 instructions in its 4-tile k-step are MOVs. Rows r and r + 8 come in as two 8-byte loads, and the MOVs rebuild the mma's A quad from them before every mma.

On items with all 4 row tiles, the branch stages X with the two rows interleaved word by word, so each quad is one 16-byte shared load. Pair p, unit u holds one quad, stored at u xor 5 on odd pairs, so loads and stores are conflict-free. The copy goes through registers, loaded a whole k-step ahead and stored after the step's math. Items with fewer tiles keep the cp.async copy and the pair reads. The path requires M == 2, so tf_nvfp4_fact_down_f32 and the prompt_* entries keep today's code.

  • 4-tile k-step: 2,291 → 1,772 instructions (13.8 per mma), MOVs 644 → 119. fact_swiglu stays at 255 registers.
  • Bit-equal: same values in the same k-slots, same mma from zero, same fma order.

Commits: ea5686db6 (the quad path for both fact entries), 98b08b2d5 (back to today's code for fact_down_f32 after the RTX PRO 4500's +1.8 % at T 2048 balanced, per the rule in #1479), and 1e627ed14 (the timer under tools/zig/). Base b30d5b9d3. Not rebased, because it applies to current main as is.

Callers. At this base the only production caller of runFact (which launches tf_nvfp4_fact_*) is Flash Next's expert tile, zig/src/families/flashnext/cuda_nvfp4_tile.zig. Bolt's IQ2_XXS experts run on the ggml kernels, and NVFP4 experts on the grouped prompt path go through runPrompt (tf_nvfp4_prompt_*). Flash Next is the only served model this reaches.

SASS

nvfp4_experts, per function, sm_80/86/89/120/121, nvcc 13.0.88 and 13.2. Only tf_nvfp4_fact_swiglu differs, on all five SMs. The other 60 functions (12 per SM, tf_nvfp4_fact_down_f32 among them) are identical. This holds against the branch base b30d5b9d3 and against current main d0c9dc9fb, merged with the branch. No other image includes a changed file.

Command: tools/zig/cuda_sass_identity.py from #1440 (not on main yet). It builds every catalog image a change reaches from both trees, with each tree's catalog flags, and compares cuobjdump -sass per function:

NVCC=/usr/local/cuda-13.0/bin/nvcc python3 tools/zig/cuda_sass_identity.py --receipt sass-13.0.tsv b30d5b9d3 HEAD
NVCC=/usr/local/cuda-13.2/bin/nvcc python3 tools/zig/cuda_sass_identity.py --receipt sass-13.2.tsv b30d5b9d3 HEAD

Without that tool: build nvfp4_experts.cu per tree with nvcc -cubin for each SM, then diff cuobjdump -sass -fun <symbol> per function.

Records

zig/src/core/contract/nvfp4_fact_records.zig: fact_swiglu is 255 registers on all five SMs (main: 249 on sm_80/86). fact_down_f32 is main's again: one row for nvcc 13.0, no 13.2 override, and the 13.3 override restored in nvcc_133_records.zig. kernel-contract-limits on all-arch fatbins confirms both fact records as current on all five SMs under nvcc 13.0. Under 13.2 fact_swiglu is current. fact_down_f32 reads 142 registers on sm_120/121 against main's 141 and is reported stale (it still passes). That is main's record and main's code, so it is unchanged here.

Timer

tools/zig/nvfp4_fact_timer.py: the synthetic-layer timer the tables below came from, as posted in #1479. It runs Flash Next's routed-expert layer (D 2560, intermediate 640, 512 experts, top-10, random NVFP4 weights, uniform routing, items of up to 64 pairs). Each kernel from two or more cubins is captured in a CUDA graph and replayed, with the order alternating by round. Every cubin's output bytes are checked against the first's, and the exit status is 1 on any difference. Build and run commands are in --help.

Measurements

fact_swiglu, synthetic layer, base → branch. Positive means slower.

T GB10 (sm_121a, this timer, 4 cold alternating processes) RTX PRO 4500 (sm_120a, @Maxrio9's harness, balanced / skewed routing)
512 flat 0.00 % / +0.52 % (inconclusive)
1,536 flat −0.16 % / −0.65 % (inconclusive)
2,048 +0.9 % +0.06 % (no 4-tile items) / −6.04 %
8,192 9.87 → 9.05 ms (−8.3 %) −9.58 % / −9.92 %

The GB10 column was measured on ea5686db6, whose fact_swiglu SASS is identical to this branch's. The RTX PRO 4500 column is @Maxrio9's measurement of ea5686db6 against b30d5b9d3 in #1479.

On GB10 the kernel is bound by its weight stream at Flash Next's default chunk. A diagnostic build with zero copy cost (wrong numbers, timing only) equals base at 1,536–2,048 rows. Served Flash Next (FP4-Local, default chunk, GB10, on ea5686db6) was level: cold TTFT 8k 4.12 / 4.18 s, 32k 16.60 / 16.53 s, first-token logits byte-equal. The gain should show at TF_FLASHNEXT_CHUNK=8192, and possibly at the default chunk on a card with more bandwidth per SM.

Checks (head 1e627ed14, GB10, nvcc 13.0)

  • tf-cuda-test nvfp4-fact: pass. Gate-up and fp32 down are bit-equal to the decode form at 1/7/17/22/64/65/130/300 rows, FP64-bounded, and the control goes red.
  • kernel-contract-limits on all-arch fatbins (-Dsm=80,86,89,120,121), nvcc 13.0 and 13.2: both fact records pass on all five SMs (13.2's down note is under Records). The only failures are 10 qwen35 tf_q35_attn_partial(8) 1024-thread launch records, in files this branch doesn't change.
  • zig fmt --check zig: clean.
  • python3 tools/zig/lean_check.py: 0 new (428 on the base, 428 on the head).
  • zig build test -Dnvcc=/usr/local/cuda/bin/nvcc: RC 0, 0 errors.
  • git merge-tree shows it applies cleanly to main d0c9dc9fb. The only open PR that touches nvfp4_experts.cu and nvfp4_fact_records.zig is my nvfp4 experts: Flash Next decode rows in equal waves, two warps a unit for one row #1351 (Flash Next decode rows), and it also merges cleanly with this branch.

Proposed check

As in #1479: the kernel contract, plus served Flash Next FP4-Local against main on a GB10. Cold TTFT at 2k, 8k and 32k, at the default chunk and at TF_FLASHNEXT_CHUNK=8192, with replies and first-token logits byte-equal.

…byte load

The fact entries loaded rows r and r + 8 as two 8-byte pairs, and the quad (a0..a3) =
(xa.x, xb.x, xa.y, xb.y) was rebuilt with MOVs before every mma: 644 MOVs in the 4-tile
k-step of tf_nvfp4_fact_swiglu (2,291 instructions, 17.9 per HMMA).

On items with all 4 row tiles (WM 1, so the CTA's warps share the layout), X is now staged
with the two rows interleaved word by word: pair p, unit u holds one quad, stored at u xor 5
on odd pairs so both the quad reads and the stores are conflict-free. The
copy goes through registers (ld.global.nc 16 bytes from each row, two 16-byte stores), loaded
a whole k-step ahead and stored after the step's math. The arithmetic is unchanged: same
values in the same k-slots, same mma from zero, same fma order. Items with fewer tiles keep
the cp.async copy and the pair reads; the prompt_* entries are untouched.

sm_121a, nvcc 13.0: the 4-tile k-step drops to 1,772 instructions (13.8 per HMMA), MOV 119.
SASS of the other 55 nvfp4_experts functions equals main's on sm_80/86/89/120/121 (13.0 and
13.2). Records re-derived with kernel-contract-limits for 13.0 (registry.cuda) and 13.2
(a cuda_132 override for the fp32 down); the 13.3 override of the down, measured before this
change, is dropped until 13.3 re-derives it.

tf-cuda-test nvfp4-fact passes (bytes equal to the decode form at 1 to 300 rows). Synthetic
Flash Next layer (D 2560, NI 640, E 512, top-10), 4 cold rounds: fact_swiglu at T 8192
9.87 -> 9.05 ms, fact_down_f32 -1.3 % (Nemotron shape -3.3 %), T 512 and 1536 flat, T 2048
fact_swiglu +0.9 %; output bytes equal in every round. Flash Next FP4 served at the default
1,536-row chunk: first-token logits equal at 8k and 32k, cold TTFT 8k 4.12 / 4.18 s and 32k
16.60 / 16.53 s (main / this, 2 rounds), texts equal.
…back on today's code

On an RTX PRO 4500 (sm_120a) the down entry measured a reproducible +1.8 % at T 2048 with balanced routing
(NT 3 items, 141 -> 192 registers), so by ashhart#1479's rule the down keeps today's code and only fact_swiglu
changes: XQ now also requires M == 2.

SASS (nvcc 13.0 and 13.2, sm_80/86/89/120/121), nvfp4_experts against b30d5b9: only tf_nvfp4_fact_swiglu
differs, on all five; the other 60 functions, tf_nvfp4_fact_down_f32 among them, are identical. Against the
previous head ea5686d only tf_nvfp4_fact_down_f32 differs, so fact_swiglu is the code measured there.
The down's records are main's again (13.0 row, no 13.2 override, the 13.3 override restored).
…r more cubins

The timer ashhart#1479's synthetic tables came from: tf_nvfp4_fact_swiglu and tf_nvfp4_fact_down_f32 on a
Flash Next expert layer (D 2560, intermediate 640, 512 experts, top-10, random NVFP4 weights, uniform
routing, items of up to 64 pairs), each kernel captured in a CUDA graph and replayed, modules in
alternating order. Every module's output bytes are checked against the first one's; the exit status is 1
if any differ. Needs torch with CUDA; the driver is called through ctypes.
@Maxrio9

Maxrio9 commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

GB10 served check: Flash Next FP4-Local, main vs #1525

Main c9bfbfad vs this PR (1e627ed14) merged onto it (tree aa7d4b39). GB10 (sm_121), driver 595.99.02, nvcc 13.0.88, Zig 0.17.0, nvidia/Qwen3.8-Flash-Next-FP4-Local@b9ce18a. Fixed token-ID prompts, greedy, 64 output tokens, prompt cache 0, parallel 1, no drafts. GPU clocks unlocked on both arms.

Per chunk: 4 server boots in ABBA order, 2 per arm. Each boot had an untimed warmup at every length, then 3 timed requests per length. That is 6 timed requests per arm per cell, but only 2 independent boots. Another job paused the run once between boots 1 and 2. The interrupted boot was discarded whole and the run resumed with the same binaries and settings.

Correctness

  • First-token logits (bf16, TF_FLASHNEXT_LOGITS) are byte-equal between main and the PR at 2k, 8k and 32k, for both chunk 1536 and chunk 8192.
  • Served token IDs and reply bytes are identical across arms, boots and repeats in every cell (96 responses including warmups). They also match the CLI's 64 greedy tokens.
  • tf-cuda-test nvfp4-fact passes on both builds. In the embedded sm_121 image, only tf_nvfp4_fact_swiglu differs (13 functions compared), and both fact records are current.

Median TTFT, main → PR

chunk prompt main PR change per boot pair main boot-to-boot drift
1536 2k 1.227 s 1.234 s +0.51 % (inconclusive) +0.10 / +1.34 % −0.77 %
1536 8k 4.449 s 4.477 s +0.64 % +0.48 / +0.93 % −0.42 %
1536 32k 17.760 s 17.852 s +0.52 % +0.69 / +0.56 % −0.11 %
8192 2k 1.120 s 1.129 s +0.75 % +0.79 / +0.78 % −0.11 %
8192 8k 4.051 s 3.968 s −2.05 % −2.77 / −0.96 % −1.25 %
8192 32k 16.382 s 16.111 s −1.65 % −2.36 / −1.22 % −1.36 %

Reading

  • Default chunk: the PR is consistently slower at 8k and 32k (+0.64 % / +0.52 %). Both boot pairs agree, main's own drift is smaller, and the PR's mean SM clock was not lower than main's. At 8k the request ranges don't overlap (main 4.412–4.467 s, PR 4.471–4.485 s). On GB10 this does not meet a no-slower bar at the default chunk. At 2k the +0.51 % is within main's own −0.77 % drift.
  • TF_FLASHNEXT_CHUNK=8192: the PR is faster at 8k and 32k, as expected from the 4-tile path. However, main varied by 1.25 % / 1.36 % between its two boots in those cells. During those prefills main also hit thermal throttling more often (8/23 vs 1/19 samples at 8k, 41/95 vs 24/91 at 32k) and ran about 1.3 % lower mean SM clock. The direction is consistent, but this run can't separate the size of the gain from the clock difference. At 2k the PR is 0.75 % slower, in line with the timer's +0.9 % at T 2048.

Reproducibility bundle (harnesses, fixtures, per-boot summaries, all timing observations, 1 Hz GPU telemetry, analyzers, source/model/binary hashes, rebuild steps): https://github.com/Maxrio9/TensorFold/releases/download/benchmark-1479-sm120-20261028/tensorfold-1525-gb10-served-results.zip

@jschmied

Copy link
Copy Markdown
Contributor Author

I'll follow up with a revised kernel version to eliminate the slow cells.

We also need to rule out thermal throttling during benchmarking by introducing cooldown periods between tests. Thermal throttling can significantly affect performance and undermine the reproducibility of benchmark results.

@ashhart

ashhart commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied. The source keeps every MMA starting from zero, the K-group and half order, the scale-FMA order and the epilogue. A symbolic check of the new load layout passes for item counts 49 to 64 and odd K tails.

The open question is speed at the default chunk. Your newer served receipt has TTFT up 0.64% at 8k and 0.52% at 32k on the default chunk, with both boot pairs agreeing and the 8k ranges not overlapping, while the 8,192-row chunk gains about 2%. Flash Next takes runFact from 64 prompt rows, including the default 1,536-row pass, so the default path would get slower. Could you keep the old function where the new layout doesn't pay (zig/kernels/cuda/nvfp4_experts.cu:188 and :400), or leave the new one opt-in until it's at least even at the default chunk? Please also recheck the 8,192-row chunk at 2k, which your receipt shows slower.

Once the default holds, we'll run tokens and first-logit bytes, alternating served boots at both chunk sizes, the five-SM limits, and per-function SASS identity for everything except tf_nvfp4_fact_swiglu.

@jschmied

Copy link
Copy Markdown
Contributor Author

Closing this. The new layout doesn't pay at the default chunk, and I found out why.

Cause: weight reuse between neighbouring items, not the X path itself. I dumped the served plans (items, counts, members) of one 8,361-token prompt at the default chunk and replayed them in the timer, with the same fact_swiglu SASS as the served builds:

  • A full chunk has T = 1,536, 11 slots (top-10 plus the shared expert) and about 519 items, 188 of them with four row tiles (138 to 221 per layer).
  • The replay reproduces the served loss: +0.9 to +3.8 % per call, and +2 to +5.4 % without the shared expert's 24 items.

Each of these changes alone leaves the sign the same:

change to the plan new vs old
partial items only −0.2 %
four-tile items only, 1× to 6× as many +3 to +5 %
every four-tile item padded to 64 pairs +3 %
synthetic 8,192-row plan folded onto 1,536 rows (X inside L2) −8.5 %

Changing only the expert ids flips it:

plan items share experts (as routed) every item its own expert
synthetic 8,192 rows, four-tile items −6.1 % +9.9 %
served 1,536-row plan, four-tile items +2.4 % +9.9 %

So the new layout wins when several items of one expert run side by side and read its weights from L2, and loses when each item streams its weights from DRAM. Under real routing at the 1,536-row chunk, an expert with four-tile items has about 1.5 of them, so the default path loses; at the 8,192-row chunk most experts carry several items, hence the gain there. I haven't measured the stall mechanism itself. Most likely the warp's wait on the X loads hides less of the weight latency.

A cutover keyed on items per expert would only help non-default chunks, so it isn't worth a second code path. Keeping weights resident across one expert's items would help the default chunk too; that would be a separate proposal if the numbers hold up.

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.

3 participants