[3/5] feat(ops): add an NVFP4 expert profile to sparse_moe - #288
MichaelDementii wants to merge 3 commits into
Conversation
…essing The four decode kernels addressed a matrix from the call site: a flat group index built from the row, and three plane pointers picked by hand. That holds while every codec keeps one scale per group per row. It does not generalise - a codec whose scale plane is swizzled needs the row to form an address, and the call site cannot compute it without already knowing the codec. A codec now receives the matrix as a plane set plus a row, with the column count as a template argument. The shared expert becomes a codec parameter in the same move: every registered profile stores it as Q8 today, but that is a property of the profiles rather than of the kernels, and the kernels no longer assert it. No behaviour change. The arithmetic, the accumulation order and the launch geometry are untouched for all four registered codecs.
…d tensor A tensor object holds one NVFP4 weight divisor, which is right for a plane quantised as one matrix and wrong for a plane assembled from several that were quantised apart. Published NVFP4 checkpoints of MoE models are the second case: each expert matrix carries its own weight_global_scale, so the converter has to refuse the pack. The tensor object gains an optional `divisors`: the number of separately quantised source matrices stacked into the plane, default 1. The payload keeps that many FP32 words immediately after the scale plane, in stacking order, so payload_bytes = weight_divisor_offset + 4 * divisors, and row r of an N-row plane uses word r / (N / divisors). A divisor's share must be a whole number of 128-row scale tiles, so a boundary never splits one. Absent means one word and the present layout exactly, so every existing artifact stays valid and no reader changes behaviour. The count is decided where the parent's shape is chosen, in the recipe plan, rather than being discovered at encode time; the converter refuses it outside NVFP4, and `validate_nvfp4_weight` refuses a plane whose divisor count does not divide its rows.
The operation admitted expert banks as Q4+Q5/Q6 or Q8+Q8 and nothing else, so an artifact whose experts are NVFP4 had no route at any token count. This registers the profile across all three: decode, small-token and prefill. Decode reads NVFP4 against represented BF16 activations. The kernels are CUDA-core dot products, so four-bit activations buy no arithmetic there and would cost a quantiser of their own; keeping the activation represented puts rel_l2 at T=1 at 1.60e-3 instead of 1.70e-1. The D4 block takes two hidden rows, because the scale plane puts rows r and r+1 in one 32-byte sector. Prefill quantises the chunk once and runs W4A4 over the existing work list, and the gate/up epilogue writes the SwiGLU intermediate already encoded, so `down` needs no separate quantiser. The per-source divisor is applied in that epilogue rather than inside the MMA loop, by a shift where the stride is a power of two. The frontier between the small-token and prefill routes is 13 for this profile, swept cold over [2,46] against its own routes rather than inherited from the groupwise ones, and the small-token schedule is chosen by the same sweep. The staging buffers the route needs are charged to this profile alone. Three launches written for it - the small-token router, the fused selection/scan/index block and a narrower route-job bound - are gated on it for the same reason: they would otherwise change what a groupwise call runs on the ragged tail of a sliced chunk. The wrapper also requires the routed and shared banks to agree on their activation divisor, because the route encodes the chunk once and both gate/up GEMMs read that one plane. The operator test walks the profile at T = 1, 2, 12, 13, 64, 768, 4097, with a separate tolerance either side of the frontier, and its fixture stacks gate and up under divisors that differ, as the published checkpoint does.
|
This is the profile itself, and the part I would most like read adversarially is the workspace: all @codex review |
Related Issue: #283
Third of five. This is the mechanism; the first two are its prerequisites, the last two turn it on.
Level of the claim: operator. The end-to-end numbers are on the Issue; they are the confirmation,
and they need commits 4 and 5 to be reachable at all.
What changes
ops::sparse_moeadmitted expert banks as Q4+Q5/Q6 or Q8+Q8 and nothing else. This registers anNVFP4 profile across all three routes — decode, small-token, prefill — because the operation has to
answer for every token count.
Decode reads NVFP4 against represented BF16 activations. The kernels are CUDA-core dot products,
so four-bit activations buy no arithmetic there and would cost a quantiser of their own; keeping the
activation represented puts
rel_l2at T=1 at 1.60e-3 instead of 1.70e-1. The D4 block takes twohidden rows, because the scale plane puts rows
randr+1in one 32-byte sector; the kernel goes10.2 → 8.0 µs and a third and fourth row start the next sector and buy nothing.
Prefill quantises the chunk once and runs W4A4 over the existing work list. The gate/up epilogue
writes the SwiGLU intermediate already encoded, so
downneeds no separate quantiser. The per-sourcedivisor is applied in that epilogue rather than inside the MMA loop — by a shift where the stride is a
power of two, which is every stride this model produces, with a division kept for the general case.
Dispatch. The frontier between the small-token and prefill routes is 13 for this profile, swept
cold over [2,46] against its own two routes rather than inherited from the groupwise 47, and the
small-token schedule comes from the same sweep.
Contract and effect on other paths
The profile costs no workspace. Its 3744 bytes a token of staging are carved out of
routed_storage— the routed SwiGLU plane it does not have, because the gate/up epilogue writes theintermediate already encoded and the reduction is folded into the shared-down epilogue. That plane
holds 8192 a token; the header states the rule for its three lifetime unions, and this is the
fourth. The bench reports a groupwise profile's arena unchanged, 43 378 176 bytes at T=1024.
Three launches written for this profile are gated on it: the small-token router, whose reduction
order differs from the MMA router's and so cannot be bit-identical; the fused selection/scan/index
block at T ≤ 8; and a narrower route-job bound. Ungated, a groupwise call would take all three on
the ragged tail of a sliced chunk, where
4096k + mproduces a final slice ofm. Each may beworth having there — a separate proposal with its own evidence, not a passenger here.
The wrapper also gains an invariant the route depends on: the routed and shared banks must agree on
their activation divisor, because the route encodes the chunk once and both gate/up GEMMs read that
one plane. Two divisors would scale the shared expert's whole contribution by their ratio, silently.
nvfp4_w4a4_mma_kernelgains two defaulted policies and an early-out so the routed launch can foldits work list into the tile index. Five shipped dense operators launch it across nine translation
units; I compiled all nine from both trees with the production line and compared SASS. 133 of 169
kernels have an identical opcode mix; the other 36 differ by 48 instructions in 237 994 —
IADDagainst
NOPpadding on most,LEA/LOP3/MOV/SHFagainstIMAD/CS2Ron six — and theraster policy computes the
blockIdxmapping master computed inline. Equivalent work, not identicalcode; I have not localised the deltas or measured whether they cost anything.
Verification
Witness against master on the profiles this does not add — an FNV-1a 64 over the decoded output of
every operator case, two binaries: identical on all 16 shipped-profile cases, with one case added
(Q8+Q8 at T=4097, which the fixture did not carry before).
Operator,
ninfer_sparse_moe_benchfrom the tree, production geometry, graph replay, 256 MiB L2flush, both routed distributions:
The fixture stacks divisors the way the artifact does — 512 rows a divisor on routed gate/up, 2048 on
routed down — so the epilogue runs its per-source path rather than a uniform one. Median µs, this
profile against Q4+Q5:
The last column is the null arm: the same binary over the same row a second time. Taken three times
over the 96 rows of the sweep, its ninetieth percentile lands at 0.29, 0.41 and 0.57 %, and its
worst single row at 1.6, 3.2 and 5.8 % — so the floor itself is only good to a factor of two, and
nothing under about a per cent here is a measurement.
The rows below T=13 are one or two ticks of the instrument. Every median the bench prints is an
exact multiple of 2.048 µs, and those rows differ by exactly that. Both passes land on the same
tick, so the direction is reproducible, but the figure is a quantisation and should be read as one
tick rather than as 3.8 %. From T=13 the difference is tens of ticks.
The other two shipped profiles are in the same sweep as controls: Q4+Q6 runs within 3 % of Q4+Q5
everywhere, and Q8+Q8 is 17 to 120 % slower than it. The comparison is against Q4+Q5 because that
is the profile this model's artifact uses.
Roofline at T=1024, trace-like: 670.9 → 1159.2 GB/s of unique weight bytes, 85.3 → 150.2 TFLOP/s
logical. This stand's own DRAM read peak, measured with
ncuon this kernel family, is 1765 GB/swith 89.8 % of it observed as attainable, so the two points are 38.0 % → 65.7 % of peak, and
73.2 % of what this kernel reaches (
raw/ncu_roofline_2026-09-17.txt). On the arithmetic side thecandidate is at 7.5 % of the NVFP4 MMA tier and the Q4+Q5 baseline at 33.7 % of the bf16 one, so
neither arm is near compute-bound: the gain is bytes moved.
Provenance: this ceiling is a direct measurement of the instruction in its product register form on
this card, two independent runs agreeing to within 0.2%. That is more honest than quoting a constant
compiled into a benchmark, but it is our own measurement, not a vendor figure. The tier figures
(2003.9 and 253.4 TFLOP/s) are the same kind of measurement taken on a different RTX 5090.
Where this profile is behind is small T, and that is where speculation runs it. A verify step
runs the MoE at T = k+1 = 4, on the small-token route, and the table puts it one tick of the
instrument behind on both distributions — a figure at the resolution limit, not a measurement of
size. End to end it does not survive: over three runs the Issue's corpus puts MTP3 at medians of
−0.1, −0.5 and −0.6 %, against a null arm on the same benchmark that moves up to 1.20 %. Decode
without speculation is unchanged.
Numerical: the test walks T = 1, 2, 12, 13, 64, 768, 4097, both sides of the frontier, with a
tolerance either side — one threshold per profile was only right while a profile had one
computational form. Represented activation: rel-L2 2.09e-3 against 2.5e-3, worst element 1.79
against 5.32. Quantised: rel-L2 1.011e-1 against 1.8e-1.
gross_relative_to_max_referencemoves 2.5e-3 → 4.0e-3, set by the output format rather than bythis fixture: BF16 keeps eight significand bits, so its relative ulp runs over (2⁻⁸, 2⁻⁷] and half
an ulp is at most 2⁻⁸ of the value. The old bound was below that floor — an element whose exact
value is 637.701 lands on 636, the nearest BF16 with the grid stepping by 4, and missed a limit of
1.66 by being correctly rounded.
relative_l2is unchanged and remains the binding term.Strength control on the lookup the profile turns on: with the divisor index pinned to the plane's
first word, all seven cases fail, by up to 1200 times the L2 bound. The fixture gives every
source matrix its own divisor over a fourfold range.
ctest120 of 120. RTX 5090sm_120a, driver 616.64, CUDA 13.1, Release, WSL2.Checks not run
[2,46]frontier sweep is not attached as raw.them lands inside an MMA loop rather than in the prologue.
because its two source matrices happen to agree. That is the demanding direction, but it means the
shared plane's stacked path is covered by the fixture and not by the artifact.
🤖 Generated with Claude Code