feat(VT-MATMUL-FP8-BLOCK-REF): add vt::MatmulFp8BlockScaled, the block-scaled fp8 GEMM whose scales cannot live in an epilogue - #1224
Merged
Conversation
…scales in the MAINLOOP, which an epilogue alpha cannot express Milestone M2 of #1189, specified before any implementation exists. The op is `vt::MatmulFp8BlockScaled`, the CPU reference arm of the 128x128 block-scaled FP8 GEMM, and it is the numerical oracle every later kernel is measured against. M1 (`ad5f175e7`) established that upstream's Triton source is not what executes and that the two arms disagree in polarity, so the same question was asked here before any arithmetic was mirrored. The answer runs through six files and it lands differently: the executing kernel is CUTLASS, and it AGREES with the reference. `vllm/model_executor/kernels/linear/__init__.py:355-377` ranks CUTLASS third; DeepGEMM is auto-disabled for `qwen3_5_text` on family 120 (`vllm/utils/deep_gemm.py:27-46`); `scaled_mm_entry.cu:220-226` routes every `sm >= 120` device to the sm120 entry; `scaled_mm_helper.hpp:39-55` takes the blockwise branch and checks both shapes with `ceil_div`; and `scaled_mm_blockwise_sm120_fp8_dispatch.cuh:218-235` hands both scale pointers to the MAINLOOP arguments over a `float` accumulator. CUTLASS 4.5.0's mainloop line is `accum(i) += tmp_accum(i) * tCrScaleAViewAsC(i) * tCrScaleBViewAsC(i)` (`sm120_mma_tma_blockwise_scaling.hpp:714-717`), which is `native_w8a8_block_matmul`'s `c += matmul(a, b.t()) * s` (`tests/kernels/quant_utils.py:145-151`) with the two scale multiplies associated left to right instead of as a product. Upstream's own test compares the two at `rel_diff < 0.001` (`test_block_fp8.py:194-200`), which is what admits that one-ULP difference and what no polarity error would survive. The constraint the row exists for: our per-tensor FP8 path folds one scalar `alpha` in the epilogue, and an epilogue has exactly one degree of freedom per output element while the block scheme has `cdiv(K, block_k)` of them. An epilogue-only application therefore cannot express a per-K-block scale AT ALL. That is a correctness constraint rather than an optimisation choice, and it is why this is a new op rather than a parameter of `kMatmulFp8Cutlass`. G4 is the instrument: two K-blocks with different scales, then the same operands with those two scales SWAPPED, which leaves every per-tensor summary of the scales identical and changes the correct answer. Ragged edges are legal and gated. The production wrapper asserts `triton.cdiv(N, block_n) == Bs.shape[0]` and `triton.cdiv(K, block_k) == Bs.shape[1]` (`fp8_utils.py:935-936`), so a short final block must work. `N=576` and `K=3884` from upstream's grid are the shapes that expose an integer-division bug and both are in the ported grid, separately and together. The upstream grid is adapted and the adaptation is stated: 125 combinations whose largest is about 9.3e11 multiply-accumulates is a GPU grid, and a naive CPU reference nest run twice cannot execute it. Every axis VALUE is preserved, the pairing is not, and the parameters, dtypes, tolerance and failure criterion are preserved exactly. M2 lands unreached, which is named here rather than discovered later. No production entry point will dispatch `vt::MatmulFp8BlockScaled` at its merge commit: `include/vllm.h` does not expose it, no loader builds an `Fp8BlockWeight`, and `ModelRegistry::Forward` has no block-FP8 linear method. Milestone M4 owns the wiring and needs M3 first. The spec lists it under `## Owed`, per the staged-slice exception in `.agents/reachability.md`. No `.agents/issue-index.md` row is appended: M1 already added the #1189 row and that file is append-only, so re-stating the issue would duplicate rather than merge. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5-1m [claude-code]
…k-scaled fp8 GEMM whose scales cannot live in an epilogue Milestone **M2** of #1189: the CPU reference arm of the 128x128 block-scaled FP8 GEMM, and the numerical oracle every later block-fp8 kernel is measured against. It takes `a_fp8 [M,K]` i8 with `a_scale [M, cdiv(K, block_k)]` f32 — exactly what `vt::QuantFp8Group` emits, landed as M1 in `ad5f175e7` — plus `b_fp8 [N,K]` i8 with `b_scale [cdiv(N, block_n), cdiv(K, block_k)]` f32, and writes `out [M,N]` f32 or bf16. Spec: [`.agents/specs/vt-matmul-fp8-block-ref.md`](.agents/specs/vt-matmul-fp8-block-ref.md), committed before this change. Pinned oracle: vLLM `5559679229bc961848b121ccdeaa8fa5d79bec98`, asserted as the local checkout's HEAD before any anchor below was read. ## The constraint that decides correctness The scales apply in the GEMM MAINLOOP, once per K-block, into an f32 accumulator — not in the epilogue: ``` acc = 0 for kt in [0, cdiv(K, block_k)): part = 0 a SEPARATE register for k in the k-tile: part += f8(a[m,k]) * f8(b[n,k]) acc += part * ( a_scale[m,kt] * b_scale[n / block_n, kt] ) ``` `vt::MatmulFp8Cutlass` folds ONE scalar alpha after the whole K reduction. An epilogue has exactly one degree of freedom per output element; this scheme has `cdiv(K, block_k)` of them. An epilogue-only application therefore cannot express a per-K-block scale AT ALL, which is why this is a new op rather than a parameter of the existing one, and why `part` stays a separate register. ## Which implementation actually runs M1 found that upstream's Triton source is not what executes and that the two arms disagree in polarity, so the same question was asked here rather than assumed. The chain: `vllm/model_executor/kernels/linear/__init__.py:355-377` ranks CUTLASS third behind FlashInfer and DeepGEMM; DeepGEMM is auto-disabled for `qwen3_5_text` on device-capability family 120 (`vllm/utils/deep_gemm.py:27-46`) and Marlin is excluded at `cc >= 89`; `scaled_mm_entry.cu:220-226` routes every `sm >= 120` device to the sm120 entry; `scaled_mm_helper.hpp:39-55` takes the blockwise branch, requires both scales f32 and 2-D, checks the shapes with `ceil_div` and refuses a bias; and `scaled_mm_blockwise_sm120_fp8_dispatch.cuh:218-235` hands both scale pointers to the MAINLOOP arguments over an `ElementAccumulator = float`. CUTLASS 4.5.0's line is `accum(i) += tmp_accum(i) * tCrScaleAViewAsC(i) * tCrScaleBViewAsC(i)` (`sm120_mma_tma_blockwise_scaling.hpp:714-717`). So the executing kernel is CUTLASS and, unlike M1, it AGREES with the reference: same placement, same polarity. The one difference is association — CUTLASS multiplies left to right where `native_w8a8_block_matmul` forms the scale product first (`tests/kernels/quant_utils.py:150-151`) — worth at most one f32 ULP per K-block, which is what upstream's own `rel_diff < 0.001` between the two admits (`test_block_fp8.py:194-200`). We mirror the reference's association, because this op IS the reference port and M5's kernel will be measured against it. ## Ragged edges `cdiv`, never floor, on both dimensions: upstream asserts `triton.cdiv(N, block_n) == Bs.shape[0]` and `triton.cdiv(K, block_k) == Bs.shape[1]` (`fp8_utils.py:935-936`), so a short final block is legal and must work. `n / block_n` indexes the b_scale row by OUTPUT COLUMN (`fp8_utils.py:823`), which agrees with a tile counter for round N and does not for ragged N. `N=576` and `K=3884` from upstream's grid are in the ported grid separately and together. ## Tests `tests/vt/test_ops_matmul_fp8_block_cpu.cpp`, registered in `tests/CMakeLists.txt`. RED first: before the op existed the focused build failed with 29 errors, 23 `'MatmulFp8BlockScaled' is not a member of 'vt'` and 6 `'kMatmulFp8BlockScaled' is not a member of 'vt::OpId'`. GREEN after: 6 cases, 80 assertions, 0 failed, in 7 s. Per block: G1 2, G2 22, G3 13, G4 14, G5 25, G6 4 — which sums to 80, so no bucket is silently empty. G4 is the instrument for the constraint above and is built so that no epilogue-folded alpha can pass it. Two K-blocks whose partials are 128 and 256 and whose scales are 0.25 and 0.5 give 160; SWAPPING the two K-block scales leaves every per-tensor summary of the scale tensor identical — same set, sum, product and max — and gives 128. Every value is exact in f32 and bf16, so these are equalities. A second subcase runs N=129, whose second N-block is one column wide, and checks each output against a hand-computed value. The upstream grid is adapted and the adaptation is written into the test. Upstream runs `itertools.product` over 125 combinations whose largest is `4096x13824x16384`, about 9.3e11 multiply-accumulates. That is a GPU grid and a naive CPU reference nest, run twice, cannot execute it. Every axis VALUE appears at least once and the ragged values appear separately and together; only the pairing is dropped, and the parameters, dtypes, tolerance and failure criterion are preserved exactly. The reference is independently written: upstream's loop nest, k-tile outer and n-block inner, in `double`, with an fp8 decode derived from the e4m3fn field layout rather than from any codec in `src/`. ## Nothing reaches this op yet `vt::MatmulFp8BlockScaled` is dispatched by no production entry point at this merge commit: `include/vllm.h` does not expose it, no loader builds an `Fp8BlockWeight`, and `ModelRegistry::Forward` has no block-FP8 linear method to call it from. The wiring is owned by #1189 milestone M4 (`layers::Fp8BlockLinearMethod` and the Qwen3.5 dense forward), which needs M3 first. This is the staged-slice exception of `.agents/reachability.md`, named here, in the pull request body, and under `## Owed` in the spec. G6 composes M1's quantizer with this GEMM end to end on a CPU queue, which is the pair a linear method will run; it is a composition test and not a reachability claim. No CUDA arm lands here. Milestone M5 owns the mainloop-scaled CUTLASS kernel for `sm_121a`, needs a GPU, and this row took no lease by design. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5-1m [claude-code]
`origin/main` moved by two commits while #1189 milestone M2 was implemented: `5ae2c100f` (GDN-MOE-BF16-OUT) and `2d26da5a1` (ENG-CUDAGRAPH-BREAK). Neither touches `include/vt/ops.h`, `src/vt/ops.cpp`, `src/vt/cpu/cpu_ops.cpp` or `src/vt/op_provider.cpp` in the regions this row adds to, and the merge is clean. It is taken before the gate run so that both range blocks execute against the tree that will land rather than against the base the row branched from. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5-1m [claude-code]
…o floor-vs-ceil mutations left the round-shape grid entirely green Fills in the spec's `## Evidence` with what the row measured rather than what it intended, taken on the merged tree with `origin/main` at `2d26da5a1`. RED: with the test present and the implementation reversed out of the four source files -- `git diff --stat` printing `4 files changed, 200 deletions(-)`, so the revert is proven to have landed rather than assumed -- the focused build fails `compile_rc=1` with 29 errors, 23 `'MatmulFp8BlockScaled' is not a member of 'vt'` and 6 `'kMatmulFp8BlockScaled' is not a member of 'vt::OpId'`. GREEN: 6 cases, 80 assertions, 0 failed. The per-block table sums to 80, so no block is silently empty, and each block was filtered through a `-tc` prefix containing no comma because doctest splits that flag on commas and a name carrying one reports `0 cases ran` under a `SUCCESS!` banner. The finding worth keeping is about the grid rather than the kernel. Two separate floor-versus-ceil mutations -- `k_tiles = k / block_k` in the kernel, and a b_scale row index of `min(col / block_n, n / block_n - 1)` -- both compiled, both ran, and both left **G2 entirely green**, because every N and every K in the round-shape block is a multiple of 128. Only G3 caught either one. That is the argument for carrying upstream's non-round shapes, `N=576` (`4*128 + 64`) and `K=3884` (`30*128 + 44`), separately and together, instead of the shapes the target checkpoint happens to have. The epilogue mutation is recorded because it is the row's whole claim: folding the per-block scales into one alpha compiles, runs, and returns the identical number for a scale tensor and for that same tensor with its two K-block entries swapped. G4 is the only block that can see it, and it does. Two instrument results ride along. Deleting the block-size positivity check took SIGFPE on integer division, and doctest had already printed `assertions: 52 | 52 passed | 0 failed` before the process died, so the run's exit status is the verdict and its summary is not. And a direct run mid-session reproduced the zeroed-store mutation's exact signature against clean sources: the binary was the one the mutation harness had last linked, and the whole-tree build that followed had not reached the test target. That false red is recorded with its control -- require `ninja: no work to do` and an executable newer than every source it links -- because it cost a cycle and will cost the next reader one too. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5-1m [claude-code]
`origin/main` advanced again while the row's evidence was being recorded, to `2a976eb9f` (ENG-CUDAGRAPH-DEDUP). It touches neither `include/vt/ops.h` nor any of the three `src/vt` files this row adds to, and the merge is clean. Taken before the final gate run so the preflight's committed-range and trailer blocks execute against the tree that will land rather than skipping on a branch that is behind. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5-1m [claude-code]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Milestone M2 of #1189:
vt::MatmulFp8BlockScaled, the CPU reference arm of the128x128 block-scaled FP8 GEMM. It is the numerical oracle every later block-fp8
kernel is measured against. It consumes exactly what
vt::QuantFp8Groupemits —M1, landed as
ad5f175e7— and stops there.Spec:
.agents/specs/vt-matmul-fp8-block-ref.md,committed before the implementation (
de5acfd8c, then4e9e5d651). Pinned oracle:vLLM
5559679229bc961848b121ccdeaa8fa5d79bec98, asserted as the local checkout'sHEAD before any anchor below was read.
The constraint that decides correctness
The scales apply in the GEMM mainloop, once per K-block, into an f32
accumulator — not in the epilogue:
vt::MatmulFp8Cutlassfolds one scalar alpha after the whole K reduction. Anepilogue has exactly one degree of freedom per output element; this scheme has
cdiv(K, block_k)of them. An epilogue-only application therefore cannot expressa per-K-block scale at all. That is a correctness constraint rather than an
optimisation choice, and it is why this is a new op rather than a parameter of the
existing one.
G4 is the instrument, and it is built so no single-alpha implementation can pass.
Two K-blocks whose partials are 128 and 256 and whose scales are 0.25 and 0.5 give
160; swapping the two K-block scales leaves every per-tensor summary of the
scale tensor identical — same set, sum, product and max — and gives 128. Every value
is exact in f32 and bf16, so these are equalities rather than tolerances. Mutating
the kernel to the epilogue form compiles, runs, and returns the same number for
both.
Which implementation actually executes upstream
M1 found that upstream's readable Triton source is not what runs and that the two
arms disagree in polarity, so the question was asked again here rather than
inherited. The dispatch decision and the kernel, each by
file:line:vllm/model_executor/kernels/linear/__init__.py:355-377qwen3_5_texton device-capability family 120vllm/utils/deep_gemm.py:27-46ops.cutlass_scaled_mm(A, B.T, scale_a=As, scale_b=Bs.T)vllm/model_executor/kernels/linear/scaled_mm/cutlass.py:312-326sm >= 120device routes to the sm120 entrycsrc/libtorch_stable/quantization/w8a8/cutlass/scaled_mm_entry.cu:220-226cutlass_scaled_mm_blockwise_sm120_fp8to the shared dispatcher.../cutlass/scaled_mm_c3x_sm120.cu:13-20ceil_div, bias refused.../c3x/scaled_mm_helper.hpp:15-18,39-55ElementAccumulator = float, and both scale pointers are mainloop arguments.../c3x/scaled_mm_blockwise_sm120_fp8_dispatch.cuh:56-58,218-235accum(i) += tmp_accum(i) * tCrScaleAViewAsC(i) * tCrScaleBViewAsC(i)include/cutlass/gemm/collective/sm120_mma_tma_blockwise_scaling.hpp:714-717So the executing kernel is CUTLASS, and unlike M1 it agrees with the
reference: same placement, same polarity, f32 accumulator, a multiply. The one
difference is association — CUTLASS multiplies left to right where
native_w8a8_block_matmulforms the scale product first(
tests/kernels/quant_utils.py:150-151) — worth at most one f32 ULP per K-block,which is precisely what upstream's own gate between the two arms admits at
rel_diff < 0.001(tests/kernels/quantization/test_block_fp8.py:194-200). Wemirror the reference's association, because this op is the reference port and
milestone M5's CUDA kernel will be measured against it.
The ragged edge is covered, and it is what the grid is for
cdiv, never floor, on both dimensions: upstream assertstriton.cdiv(N, block_n) == Bs.shape[0]andtriton.cdiv(K, block_k) == Bs.shape[1](
fp8_utils.py:935-936), so a short final block is legal and must work.n / block_nindexes the b_scale row by output column (fp8_utils.py:823),which agrees with a tile counter for round N and does not for ragged N.
G3 runs upstream's own non-round shapes —
N=576(4*128 + 64) andK=3884(
30*128 + 44) — separately and together, plus upstream's dedicated DSV3kv_a_proj_with_mqacaseM=32, N=576, K=7168(test_block_fp8.py:156-200).This is measured rather than asserted. Two separate floor-versus-ceil mutations —
k_tiles = k / block_kin the kernel, and a b_scale row index ofmin(col / block_n, n / block_n - 1)— each compiled, each ran, and each left G2entirely green, because every N and every K in the round-shape block is a multiple
of 128. Only G3 caught either. A grid of round shapes passes while being wrong,
which is why the checkpoint's own all-multiple-of-128 shapes are not the gate.
Tests and evidence
tests/vt/test_ops_matmul_fp8_block_cpu.cpp, registered intests/CMakeLists.txt.RED, re-taken on the merged tree with the implementation reversed out of the
four source files (
git diff --statprinting4 files changed, 200 deletions(-),so the revert is proven to have landed): the focused build fails
compile_rc=1with29 errors — 23
'MatmulFp8BlockScaled' is not a member of 'vt'and 6'kMatmulFp8BlockScaled' is not a member of 'vt::OpId'.GREEN: 6 cases, 80 assertions, 0 failed, 3.4 s. Per block, filtered through a
-tcprefix that contains no comma, because doctest splits that flag on commas anda name carrying one reports
0 cases ranunder aSUCCESS!banner:The buckets sum to the whole-run count, so no block is silently empty and no filter
selected nothing.
test_op_provider,test_ops_quant_fp8_group_cpuandtest_ops_fp8_cpuall pass unchanged.scripts/agent-preflight.sh --fail-on-skipreports All gates green with 80
ok, noFAILand noSKIPbefore the verdictline;
test_cpu_x86_llamacpp_floor(#618) passed rather than reportingNO_QUIET_WINDOW, so no pristine-baseline reproduction was owed.Ten mutations, each printing
compile_rcandgit diff --statbecause amutation that fails to build and a mutation that never applied both read as a
passing test. One of the ten did exactly that — the b_scale index forced to a
constant, which orphaned
block_nand died on-Werror=unused-parameter— and it isreported as proving nothing and re-run in a form that keeps the parameter live. The
full table is in the spec's
## Evidence, along with a false red that cost a cycle:a direct run reproduced the zeroed-store mutation's exact signature against clean
sources, because the binary was the one the mutation harness had last linked.
The upstream grid is adapted and the adaptation is written into the test. Upstream
runs 125 combinations whose largest is
4096x13824x16384, about 9.3e11multiply-accumulates; that is a GPU grid, and a naive CPU reference nest run twice
cannot execute it. Every axis value appears at least once and the ragged values
appear separately and together; only the pairing is dropped, and the parameters,
dtypes, tolerance and failure criterion are preserved exactly. The reference is
independently written: upstream's loop nest in
double, with an fp8 decode derivedfrom the e4m3fn field layout rather than from any codec in
src/.Nothing reaches this op yet
vt::MatmulFp8BlockScaledis dispatched by no production entry point at this mergecommit:
include/vllm.hdoes not expose it, no loader builds anFp8BlockWeight,and
ModelRegistry::Forwardhas no block-FP8 linear method to call it from. Thewiring is owned by #1189 milestone M4 (
layers::Fp8BlockLinearMethodand theQwen3.5 dense forward), which needs M3 first. This is the staged-slice exception of
.agents/reachability.md, named here, in the commitbody, and under
## Owedin the spec. G6 composes M1's quantizer with this GEMM endto end on a CPU queue — a composition test, not a reachability claim.
No CUDA arm lands here: milestone M5 owns the mainloop-scaled CUTLASS kernel for
sm_121a, needs a GPU, and this row took no lease by design. No.agents/issue-index.mdrow is appended either: M1 already added the #1189 row andthat file is append-only, so re-stating the issue would duplicate rather than merge.
Closes nothing; #1189 stays open for M3 through M6.
FOLLOWING_AGENTS_PROTOCOL
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5-1m [claude-code]