Skip to content

The NVFP4 TMA route reads activation scales as one tile: operator 0.81x to 0.87x, prefill +4.1%, bitwise identical - #160

Open
MichaelDementii wants to merge 1 commit into
Neroued:masterfrom
MichaelDementii:perf/nvfp4-blocked-scales
Open

The NVFP4 TMA route reads activation scales as one tile: operator 0.81x to 0.87x, prefill +4.1%, bitwise identical#160
MichaelDementii wants to merge 1 commit into
Neroued:masterfrom
MichaelDementii:perf/nvfp4-blocked-scales

Conversation

@MichaelDementii

Copy link
Copy Markdown
Contributor

The activation scale descriptor is a 16-byte box over the scale plane, so one stage of the TMA
pipeline asks for BlockM separate 16-byte transfers to deliver 4 KiB. The weight codes beside it
travel as one 64-byte box per row and cost nothing like that.

A standalone harness that runs the producer alone - the same four descriptors, the same 29,696-byte
stage, the same three-deep mbarrier ring, no math - shows where the stage time goes: the codes alone
move at 6977 GB/s; adding the weight scales costs 14.8% more time for 4.2% more bytes; adding the
activation scales costs 73.0% more time for 16.7% more bytes. Requesting the same bytes as one
[BlockM tokens, 16 groups] tile restores the codes-only rate, 7009 GB/s against 4732 for the same
payload.

The byte order inside a tile is unchanged, so the shared image the consumer reads is identical and
the consumer is untouched: only the address the quantizer writes to, the descriptor shape and the
request coordinate move. The quantizer writes the plane blocked when the GEMM that follows will
take the TMA route and keeps it row-major otherwise, because the small-T and GEMV routes read the
same buffer. The predicate is the route's own; the fused SwiGLU route admits every multiple of 256
from 256 up, which is wider than the shared one.

RTX 5090, sm_120a, 510 W cap, 3105 MHz boost, CUDA 13.1.115, Release. Both builds come from one
tree, interleaved ABAB, three rounds, one process per run.

Operator, T=4096, NVFP4 A4:

  shape                             before us   after us  ratio       GB/s        TFLOP/s
  linear_add    [5120, 17408]         759.808    612.352  0.806   364 -> 452   961 -> 1192
  linear_add    [5120,  6144]         303.104    262.144  0.865   501 -> 580   850 ->  983
  linear_swiglu [34816, 5120]        1456.128   1214.464  0.834   196 -> 234  1003 -> 1202

Against the hardware limits: the GDDR7 spec peak is 1792 GB/s, so these shapes sit between 11% and 28% of it
before and between 13% and 32% after - the route is nowhere near bandwidth-bound at either end, and
what the change buys is transaction width rather than bandwidth. For the tensor core, the
block-scaled NVFP4 MMA measures 2117 TFLOP/s in isolation on this part, and 1897 when fed in this
kernel's own ratio of 4 ldmatrix.x4 and 8 ldmatrix.x2 per 32 MMA; against that 1897 the three shapes
move from 51%, 45% and 53% to 63%, 52% and 63%. Those two ceilings were taken at full boost and
these runs did not sustain it, so the tensor-core fractions are a lower bound; the bandwidth
fractions are not affected, memory speed being independent of the SM clock.

Controls in the same runs, below the 256-token floor where the route is not taken: linear_add at
T=64 and T=128, fused SwiGLU at T=64. All three are 1.000 to the printed digit, which is what shows
the two builds are otherwise identical.

End-to-end confirmation, Qwen3.6-27B NVFP4, 32768-token prompt, prefill chunk 4096, KV int8:
prefill 9426.4 -> 9811.8 tok/s (+4.09%), 3.4762 -> 3.3397 s, with round-to-round spreads of 101.4
and 98.6 tok/s. Decode is the untouched control at 87.945 -> 87.958 tok/s (+0.01%): its token counts
never reach the tile.

Resource deltas. Workspace capacity and peak, weight capacity and KV payload are byte-identical
between the builds as the engine reports them: 639,926,272, 17,206,931,200 and 1,107,296,256. No
kernel changes registers or shared memory, and none of the 37 functions in the touched translation
units spills. The binary gains three kernel instantiations: the quantizer template takes a
flat/blocked flag, so each of the three activation geometries is emitted twice, at 40 registers and
no shared memory.

Numerical validation. Values do not change - only the address a scale byte is written to - and
greedy generation matches the previous binary byte for byte on four prompts of 8K, 64K and two of
55K tokens. ctest is 102 of 102, with six cases skipped for want of real model artifacts.

clang-format 23.1.0 reports eight violations across these files. All of them are on lines this
change does not touch, so they are left as they are.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-09T19:31:14.513779Z 8881952 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Neroued

Neroued commented Sep 2, 2026

Copy link
Copy Markdown
Owner

what about T=1024?

@Neroued

Neroued commented Sep 2, 2026

Copy link
Copy Markdown
Owner

For the tensor core, the
block-scaled NVFP4 MMA measures 2117 TFLOP/s

check the RTX5090 spec for the number, I remeber it's 1736 TFLOPS for nvfp4

@Neroued

Neroued commented Sep 2, 2026

Copy link
Copy Markdown
Owner

For the tensor core, the
block-scaled NVFP4 MMA measures 2117 TFLOP/s

check the RTX5090 spec for the number, I remeber it's 1736 TFLOPS for nvfp4

AH 3105 MHz boost ok

@MichaelDementii

Copy link
Copy Markdown
Contributor Author

T=1024 is where the route turns on and it is the shipped default, so it should have led the table. Same tree, interleaved, three rounds, ratio new/old:

                              T=768  T=1024  T=1280  T=2048  T=4096  T=8192
linear_add    [5120, 17408]   1.000   0.781   0.883   0.802   0.806   0.814
linear_add    [5120,  6144]     -     0.857   0.906   0.846   0.871   0.865
linear_swiglu [34816, 5120]     -     0.887     -     0.846   0.835     -

T=768 is the control below the floor. End-to-end at chunk 1024, same 32768-token prompt: 8607.6 -> 9123.5 tok/s (+5.99%, spreads 110 and 95) against the +4.09% I reported at 4096; decode unchanged. T=1280 is the sweep's weakest point - five 256-token tiles sit on the grid worse than their neighbours.

Happy to fold these into the PR body if you would rather have them there than in the thread.

Gevil pushed a commit to Gevil/ninfer that referenced this pull request Sep 4, 2026
Gevil added a commit to Gevil/ninfer that referenced this pull request Sep 6, 2026
…pair Neroued#167/Neroued#160, dylan wave 2 (7dd98fd), gzenz safety net (08636ed), md fp8-KV campaign, T22-T27 plan
@MichaelDementii

Copy link
Copy Markdown
Contributor Author

A correction to one number in the body, before it trips anyone up.

I re-measured the NVFP4 tier directly: back-to-back MMAs with operands in registers, the result
fed back into the accumulator on every instruction, the store behind a runtime-false gate, and the
SM clock read inside the kernel with clock64() rather than taken from the device properties. The
instruction issues as OMMA.SF.16864.F32.E2M1.E2M1.UE4M3.4X, and it retires exactly 4096 FLOP
per SM per clock
— measured 4094.8 to 4095.8 across two runs and eight block/chain shapes. So
the tier peak is just 170 x 4096 x f_SM, and the only variable is the clock.

On the 5090 I have in front of me (3105 MHz nominal boost, 525 W cap) the highest SM clock I can
observe from inside a kernel is 2.91 GHz under a cheap FFMA load and 2.87-2.88 GHz under
this MMA itself, which puts the tier at 2004-2020 TFLOP/s, two independent runs 0.3% apart.
The 2117 I quoted needs 3.04 GHz; it came from an earlier run on a different rented card with a
higher boost ceiling, and I should not have carried it over to a part that does not reach it.

The three percentages in the table divide by the fed ceiling of 1897, not by 2117. That one was
taken on the same earlier card, so it is high by the same clock ratio, and correcting it moves the
percentages up rather than down — the claim is not affected in the direction that would flatter
it. If it is useful I would rather state the tier as 4096 FLOP per SM per clock and let the number
follow whatever clock the part actually holds; the probe is a few lines and I can attach it.

kaushikvira added a commit to kaushikvira/ninfer that referenced this pull request Sep 7, 2026
…(port PR Neroued#160)

Cherry-pick 545f64b (MichaelDementii): activation-scale TMA descriptor
was BlockM separate 16-byte requests per 4KiB tile; now one [BlockM tokens,
16 groups] tile. Byte order within a tile unchanged -> consumer identical;
quantizer writes blocked only when the following GEMM takes the TMA route
(small-T/GEMV stay row-major), BlockM=256 everywhere (verified on our 27B
geometry: TmaM256N128). Measured by author on Qwen3.6-27B NVFP4: prefill
+4.09% (32k prompt, chunk 4096), decode +0.01%, greedy bit-identical,
ctest 102/102. Our A/B: pre-A 178.5 tok/s / 10.2k-3.5k prefill / needle
12/12 (2026-09-07).
@kaushikvira

Copy link
Copy Markdown

Independent verification on our rig (cometkim Qwen3.8-27B nvfp4full, our fork at upstream 487f897, commit 7e8ad2e):

Same probes, same session, before/after (cherry-pick applied cleanly, zero conflicts on 487f897):

metric pre post Δ
decode 178.5 180.7 tok/s +1.2%
prefill 12.6k 10,201 10,330 tok/s +1.3%
prefill 50k 7,477 7,663 tok/s +2.5%
prefill 100k 5,432 5,528 tok/s +1.8%
prefill 200k 3,458 3,493 tok/s +1.0%
needle (long-context retrieval) 12/12 12/12 same

Context for the smaller-than-your-claims deltas: our server runs --prefill-chunk 2048 (not 4096), 450 W power cap (not 510/525), and the prompt sizes differ from your 32768-token Qwen3.6 run — so I'd expect a smaller but positive effect, which is exactly what we got. No regression on decode or KV capacity; workspace/weights footprints byte-identical as you reported.

Also note: dashboard_v2 on our box (RTX 5090, CUDA 13.1, sm_120a) — the T=1024 numbers you added (0.78–0.86) reproduce the trend; we didn't run the operator harness separately, just the engine end-to-end.

Happy to share the config (bench/configs/ninfer-nvfp4full-grafted-dflash2.cfg in github.com/kaushikvira/ninfer) if useful.

@Neroued — this PR is safe to merge: bitwise-identical greedy output on our 8K/55K/64K prompts + ctest 102/102 (as you stated), and it improves prefill at the shipped default chunk 2048 too.

kaushikvira added a commit to kaushikvira/ninfer that referenced this pull request Sep 7, 2026
… tooling')

main is upstream 487f897 + 9 commits: own nvfp4full registration + graft
tool, the Neroued#148/Neroued#97/Neroued#160/Neroued#61 PR ports (temp, drop as they merge), the
registry doc, and curl for healthchecks. Registry table in PATCHES.md now
covers all 9 (added curl + registry rows, date -> 2026-09-15).
The activation scale descriptor is a 16-byte box over the scale plane, so one stage of the TMA
pipeline asks for BlockM separate 16-byte transfers to deliver 4 KiB. The weight codes beside it
travel as one 64-byte box per row and cost nothing like that.

A standalone harness that runs the producer alone - the same four descriptors, the same 29,696-byte
stage, the same three-deep mbarrier ring, no math - shows where the stage time goes: the codes alone
move at 6977 GB/s; adding the weight scales costs 14.8% more time for 4.2% more bytes; adding the
activation scales costs 73.0% more time for 16.7% more bytes. Requesting the same bytes as one
[BlockM tokens, 16 groups] tile restores the codes-only rate, 7009 GB/s against 4732 for the same
payload.

The byte order inside a tile is unchanged, so the shared image the consumer reads is identical and
the consumer is untouched: only the address the quantizer writes to, the descriptor shape and the
request coordinate move. The quantizer writes the plane blocked when the GEMM that follows will
take the TMA route and keeps it row-major otherwise, because the small-T and GEMV routes read the
same buffer. The predicate is the route's own; the fused SwiGLU route admits every multiple of 256
from 256 up, which is wider than the shared one.

RTX 5090, sm_120a, 510 W cap, 3105 MHz boost, CUDA 13.1.115, Release. Both builds come from one
tree, interleaved ABAB, three rounds, one process per run.

Operator, T=4096, NVFP4 A4:

  shape                             before us   after us  ratio       GB/s        TFLOP/s
  linear_add    [5120, 17408]         759.808    612.352  0.806   364 -> 452   961 -> 1192
  linear_add    [5120,  6144]         303.104    262.144  0.865   501 -> 580   850 ->  983
  linear_swiglu [34816, 5120]        1456.128   1214.464  0.834   196 -> 234  1003 -> 1202

Against the hardware limits: the GDDR7 spec peak is 1792 GB/s, so these shapes sit between 11% and 28% of it
before and between 13% and 32% after - the route is nowhere near bandwidth-bound at either end, and
what the change buys is transaction width rather than bandwidth. For the tensor core, the
block-scaled NVFP4 MMA measures 2117 TFLOP/s in isolation on this part, and 1897 when fed in this
kernel's own ratio of 4 ldmatrix.x4 and 8 ldmatrix.x2 per 32 MMA; against that 1897 the three shapes
move from 51%, 45% and 53% to 63%, 52% and 63%. Those two ceilings were taken at full boost and
these runs did not sustain it, so the tensor-core fractions are a lower bound; the bandwidth
fractions are not affected, memory speed being independent of the SM clock.

Controls in the same runs, below the 256-token floor where the route is not taken: linear_add at
T=64 and T=128, fused SwiGLU at T=64. All three are 1.000 to the printed digit, which is what shows
the two builds are otherwise identical.

End-to-end confirmation, Qwen3.6-27B NVFP4, 32768-token prompt, prefill chunk 4096, KV int8:
prefill 9426.4 -> 9811.8 tok/s (+4.09%), 3.4762 -> 3.3397 s, with round-to-round spreads of 101.4
and 98.6 tok/s. Decode is the untouched control at 87.945 -> 87.958 tok/s (+0.01%): its token counts
never reach the tile.

Resource deltas. Workspace capacity and peak, weight capacity and KV payload are byte-identical
between the builds as the engine reports them: 639,926,272, 17,206,931,200 and 1,107,296,256. No
kernel changes registers or shared memory, and none of the 37 functions in the touched translation
units spills. The binary gains three kernel instantiations: the quantizer template takes a
flat/blocked flag, so each of the three activation geometries is emitted twice, at 40 registers and
no shared memory.

Numerical validation. Values do not change - only the address a scale byte is written to - and
greedy generation matches the previous binary byte for byte on four prompts of 8K, 64K and two of
55K tokens. ctest is 102 of 102, with six cases skipped for want of real model artifacts.

clang-format 23.1.0 reports eight violations across these files. All of them are on lines this
change does not touch, so they are left as they are.
@MichaelDementii
MichaelDementii force-pushed the perf/nvfp4-blocked-scales branch from 545f64b to 8881952 Compare September 9, 2026 10:30
@MichaelDementii

MichaelDementii commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto b88c0f6f; head is now 8881952f. The branch was 99 commits behind and conflicting,
and the conflict turned out to be worth explaining rather than just resolving.

The conflict, and why the change is still worth having

Master gained ee9d5192 since this branch was written — my own merged commit, "fetch each
activation-scale box once". It targets the same load this PR targets. That could have made this PR
redundant, so I checked before rebasing.

It does not. The two are different mechanisms on the same fetch and they compose:

  • ee9d5192 reduces how often the box is fetched. TMA cannot address a box narrower than 16
    bytes and 16 bytes of scales cover two K tiles, so it loads on the even tile only, into a
    two-slot buffer indexed by (k_tile / 2) & 1.
  • This PR changes the shape of each fetch. The quantiser writes activation scales
    tile-contiguous, so one request reads a [BlockM tokens, 16 groups] tile — BlockM bytes wide
    and 16 rows tall — instead of BlockM separate 16-byte rows.

The resolution keeps master's guard and its two-slot indexing and takes only the coordinates from
this branch, since the box is now addressed by tile index rather than by a (byte column, token row)
pair. The consumer already reads [(k_tile / 2) & 1] on master and is unchanged.

What it is worth now

The numbers in the body above predate ee9d5192 and overstate what is left. Re-measured on
today's master, one card, one session, SM clock recorded (2432 to 2448 MHz), arms built from the
same worktree with the binaries kept and their difference asserted; three passes with the arm order
rotated, ratios paired inside a pass, median over passes per T then over T, CI from a paired
bootstrap, null = a second build of master through the same rotation:

shape T this PR CI95 null p95
14336x5120 1024..8192, nine points -8.44 % [-9.49, -6.87] 0.276 %
34816x5120 1024..8192, nine points -7.64 % [-8.84, -6.91] 0.160 %
5120x17408 1024..8192, nine points -9.86 % [-10.85, -7.97] 0.189 %

Every individual T is negative on all three shapes, from -6.0 % to -9.9 %. So the operator gain is
smaller than the 0.81x-0.87x this PR originally claimed - ee9d5192 took part of it - but it is
still there, at every token count the route serves.

Correctness

Numeric gate rather than a pass token: the four NVFP4 op tests were run on master and on this head
with NINFER_OP_REPORT_STATS=1, which prints max_abs, max_rel and rel_l2 per case at
%.17g. 85 numeric records, byte-identical on both sides (15 / 24 / 26 / 20), and all four
report OK on both. The gate asserts the two sides are different builds first: 4 of 4 binaries
differ.

I have not re-taken the end-to-end prefill numbers in the body; they belong to the older base and I
am not presenting them as current.

@MichaelDementii

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

Provided git ref 8881952fdbad553314ea95c4e8f449fbe245134d does not exist
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@MichaelDementii

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: 8881952fdb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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