Skip to content

[CUDA] Block-scaled MatMul decode GEMV: tensor cores, packed FP4 decode and M-tiling - #31155

Merged
tianleiwu merged 9 commits into
mainfrom
tlwu/20260730/block_scaled_gemv_decode
Aug 1, 2026
Merged

[CUDA] Block-scaled MatMul decode GEMV: tensor cores, packed FP4 decode and M-tiling#31155
tianleiwu merged 9 commits into
mainfrom
tlwu/20260730/block_scaled_gemv_decode

Conversation

@tianleiwu

@tianleiwu tianleiwu commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Description

Five optimizations to the decode GEMV fast path of the MatMulBlockQuantizedFp4Weight and
MatMulBlockQuantizedFp8Weight contrib ops. All are behind existing kill switches
(ORT_FP4_GEMV_MMA=0, ORT_FP8_GEMV_MMA=0, ORT_FP4_GEMV_ROW_TILING=0) for A/B.

commit change
fp8 blocked scaled gemm hoist the fp32 widening out of the inner loop when RowsPerWarp > 1
prmt quad decode decode four E2M1 codes per prmt.b32 instead of one
FP4 dense GEMV: grid-gated row tiling tile over M, gated on the column-block count
Run FP8 weight-only decode GEMV on tensor cores mma.m16n8k16
Run the NVFP4 decode GEMV on tensor cores mma.m16n8k16

Motivation and Context

The shipped GEMV kernels were tuned for M = 1 (plain autoregressive decode). Speculative /
MTP decoding makes the verify forward M > 1 wide, and at M = 4 the M = 1 tuning inverts:

  • cuBLAS fp16 gets faster per byte (3.1 TB/s — it amortizes the weight read over 4 rows);
  • the FP8 GEMV gets slower per byte (2.35 -> 1.25 TB/s), because it re-widens B to fp32 once
    per row and A once per column, so it goes ALU-bound.

Measured on H200 (µs, shipping <R,1,1> kernel vs cuBLAS) — flipping dense projections to FP8
without touching the kernel was a regression at M = 4:

shape (NxK) M=1 cuBLAS / fp8 M=4 cuBLAS / fp8
in_proj_qkv 8192x2048 10.9 / 7.1 10.9 / 13.7
in_proj_z 4096x2048 8.3 / 4.8 8.3 / 8.1
out_proj 2048x4096 8.8 / 5.1 8.3 / 9.1

All numbers below: 1x H200 SXM (SM90, 132 SM, ~4.8 TB/s HBM), CUDA 13.0, Qwen3.6-35B-A3B-NVFP4

  • MTP N=3 (verify batch M=4).

1. FP8: hoist the fp32 widening for RowsPerWarp > 1 — −0.13 ms/step

M == 1 keeps the original scalar path (hoisting measured slightly worse there). For R > 1,
B is widened once per (col, half) and reused by all rows, A once per (row, half) and reused
by all cols; the 16-element chunk is consumed in two 8-element halves to cap live registers.
Instructions per 16 weight bytes: 8 + 48*R -> 8*C + 16*R + 16*C + 16*R*C (200 -> 120 per
column at R=4, C=2). The fma order is unchanged, so the output is bit-identical.

After the fix, M=4 FP8 beats cuBLAS by 1.04–1.59x and the old <R,1,1> by 1.13–1.41x.
End-to-end 2x2 (graph ON, 400 steps, 64 warmup, 2 reps), ms/step:

old kernel new kernel
v10 (fp16 dense) 10.80 10.711
v11 (FP8 dense) 10.90 10.674

10.80 -> 10.674 = −0.13 ms/step (−1.2%). Acceptance rate unaffected (2.5–2.75 tok/step in
every cell).

2. FP4: prmt quad decode — −0.26 ms/step (−2.4%)

prmt.b32 is a 4-byte table lookup in one instruction, so a whole 32-bit word (8 E2M1 codes)
is decoded at once instead of per element.

shape M scalar µs prmt µs speedup
lm_head 248320x2048 1 186.97 157.03 1.19x
lm_head 2 368.90 310.01 1.19x
lm_head 4 733.17 613.70 1.19x
shared gate/up 512x2048 4 4.76 4.22 1.13x
shared down 2048x512 4 6.01 5.14 1.17x

SASS instruction count: half 352 -> 288 (−18%), bf16 376 -> 336 (−11%).
End-to-end (graph ON, 400 steps, 3 interleaved reps): 10.766 -> 10.504 ms/step; every prmt
rep beats every baseline rep. Bit-identical — an exhaustive 256-byte sweep against the old
Raw()*Scale() path gives 0 mismatches for both half and bf16.

3. FP4: grid-gated row tiling — −0.14 ms/step (−1.4%)

Process RowsPerBlock rows of Y at once so the uint4 weight load, the prmt decode and the
scale load are amortized across rows. Gated on the column-block count, because at small N the
grid is already narrow and tiling loses:

shape N col blocks untiled µs tiled µs speedup
lm_head 248320 31040 615.8 537.7 1.15x
shared down 2048 256 4.30 3.33 1.29x
shared gate/up 512 64 3.47 4.27 0.81x (gate keeps this untiled)

FP4 GEMV family (graph OFF): 1.099 -> 1.066 (launch bounds) -> 0.950 ms/step (1.157x).
End-to-end (graph ON, 200 steps, 4 interleaved reps): 10.448 -> 10.306 ms/step.
Bit-identical (per-row fp32 accumulation order unchanged).

4. FP8 on tensor cores (mma.m16n8k16) — −0.27 ms/step (−2.7%)

shape M FMA µs MMA µs speedup
8192x2048 1 6.3 5.1 1.24x
8192x2048 4 9.8 5.2 1.88x
8192x2048 8 17.5 5.7 3.07x
4096x2048 1 4.8 4.0 1.20x
4096x2048 4 6.9 4.1 1.68x
2048x4096 1 5.1 4.2 1.21x
2048x4096 4 8.0 4.4 1.82x

FP8 GEMV family (graph OFF, 130 launches/step): 1.052 -> 0.713 ms/step (1.48x); all kernels
7.368 -> 7.021 ms/step. End-to-end 9.80 -> 9.54 ms/step. Now at 1.28 GB/step = 1.8 TB/s
= 37% of HBM peak.

Not bit-identical, but not worse: E4M3->FP16 is lossless and the f16 x f16 products are exact
in fp32, so max error against an FP64 reference is identical for both kernels (2–4e-4).

5. FP4 on tensor cores (mma.m16n8k16) — −0.55 ms/step (−5.8%)

Largest single win of the campaign.

shape launches/step scalar µs MMA µs speedup
lm_head N=248320 K=2048 1 0.5376 0.1083 4.96x
gate/up N=512 K=2048 80 0.2786 0.2391 1.17x
down N=2048 K=512 40 0.1327 0.1007 1.32x
FP4 GEMV family total 121 0.9488 ms 0.4480 ms 2.12x

All kernels: 7.018 -> 6.526 ms/step. End-to-end (graph ON, 5 interleaved reps):
9.768 / 9.814 / 9.824 -> 9.558 / 9.584 / 9.466, i.e. 9.54 -> 8.99 ms/step.
Bit-identical (E2M1 decode order and fp32 accumulation order unchanged). The kernel now sits
at 56% of HBM peak, and two further optimizations measured 0%, so this path is considered closed.

Tests

  • onnxruntime/test/contrib_ops/matmul_block_scaled_fp4_test.cc
  • onnxruntime/test/contrib_ops/matmul_block_scaled_fp8_test.cc — including
    GemvSpeculativeDecodeTilesFp16 (m in {2,3,4} x n in {1026,2050,4098,8194}, ragged tails,
    row- and column-varying data).

Methodology note

All end-to-end deltas are quoted as ms/step from a fixed-step measurement, never tok/s: any
numerics change alters the generated sequence and therefore the MTP acceptance rate, which swamps
the speed delta. Per-kernel durations are taken with CUDA graphs off
nsys --cuda-graph-trace=node inflates durations ~35% globally and up to 3.8x for large-grid
kernels (FP4 lm_head: 2840 µs reported vs 741 µs real).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can commit the suggested changes from lintrunner.

Comment thread onnxruntime/contrib_ops/cuda/math/matmul_block_scaled_fp4.cu Outdated
Comment thread onnxruntime/contrib_ops/cuda/math/matmul_block_scaled_fp4.cu Outdated
The decode GEMV mapped M onto gridDim.y, so the packed NVFP4 weight was streamed
from DRAM once per row of A. Speculative decoding / MTP verify runs M = N_spec + 1
rows and paid M x the weight traffic.

Template the kernel on RowsPerBlock (1, 2 or 4): a warp now accumulates several
rows at once and the uint4 weight load, the prmt E2M1 decode and the two E4M3
block scales are shared across the tile. Per-row fp32 accumulation order is
unchanged, so results are bit-identical to RowsPerBlock == 1.

Tiling removes M from gridDim.y, so it is gated on the column grid ceil(N / 8)
covering at least one full wave of SMs on its own. H200, M = 4, half:

  N = 248320 (lm_head)     31040 col blocks   615.8 -> 537.7 us  (1.15x)
  N =   2048 (shared down)   256 col blocks     4.30 ->  3.33 us  (1.29x)
  N =    512 (shared gate)     64 col blocks     3.47 ->  4.27 us  (0.81x, gated off)

Also pin __launch_bounds__ per tile. Left unconstrained nvcc compiles the 4-row
tile to 66 registers, which drops the block from 4 to 3 per SM; the pinned
budgets are spill-free and worth ~7% on the large shapes by themselves.

Qwen3.6 NVFP4 MTP decode on H200: FP4 dense GEMV 1.099 -> 0.950 ms/step (1.16x),
end-to-end 10.448 -> 10.306 ms/step (-1.4%).

Set ORT_FP4_GEMV_ROW_TILING=0 to force RowsPerBlock == 1.
The fused FP8 GEMV re-widens both operands to FP32 and does scalar FMAs, so at
M > 1 it is issue bound rather than bandwidth bound: at M = 4 a lane runs ~240
instructions per 32 weight bytes and effective bandwidth drops from ~2.35 TB/s
(M = 1) to ~1.25 TB/s.

Add MatMulBlockScaledFp8MmaGemvKernel, which does the dot products with
mma.m16n8k16 and FP32 accumulate. The weight feeds the mma A operand and the
activation the mma B operand, so both fragments match the tensors' natural
row-major layouts with no transpose or shared-memory staging; the mma "M"
extent becomes the output column count (16 per warp) and the mma "N" extent
becomes M. Fragment loads are coalesced by permuting the K axis - legal because
K is a reduction axis and the same permutation is applied to both operands -
which lets each lane load one contiguous uint4 of weight bytes per 64-element K
window and feed four mma steps from it. KSplit warps per block take a strided
share of the K windows and reduce through shared memory, restoring the
memory-level parallelism lost by widening each warp to 16 columns.

Used on SM80+ when K % 64 == 0, K >= 256 and block_size % 64 == 0; otherwise
the existing kernel runs unchanged. Set ORT_FP8_GEMV_MMA=0 to force it.

Not bit-identical to the FMA path but not less accurate: E4M3 to FP16/BF16 is
lossless, the products are exact in FP32, and both kernels accumulate in FP32.
Scored against an FP64 reference the maximum error is identical for the two.

H200, standalone (us), FMA kernel -> mma kernel:

  8192x2048  M=1   6.3 -> 5.1    M=4   9.8 -> 5.2    M=8  17.5 -> 5.7
  4096x2048  M=1   4.8 -> 4.0    M=4   6.9 -> 4.1    M=8  10.1 -> 4.3
  2048x4096  M=1   5.1 -> 4.2    M=4   8.0 -> 4.4    M=8  13.0 -> 4.5
   512x2048  M=1   3.3 -> 3.1    M=4   4.7 -> 3.3    M=8   6.4 -> 3.3

Faster at every measured M, so it is preferred whenever its preconditions hold
rather than only for M > 1.

On a 40-layer Qwen3.6 NVFP4 MTP decode (130 FP8 matmul nodes per step, M = 4)
the FP8 GEMV kernel family goes 1.052 -> 0.713 ms/step and the model goes
9.80 -> 9.54 ms/step, with no other kernel family affected.
The fused NVFP4 decode GEMV gave each warp one output column and reduced over K
with __shfl_down. That makes the warp re-read the whole A tile once per output
column: at RowsPerBlock = 4 it pulls 16 KB of activation for 1 KB of weight, a
16:1 amplification that dominated lm_head (N = 248320, 537.6 us on H200).

On SM80+ with K % 128 == 0 and M <= 8, replace the shuffle reduction with
mma.m16n8k16 so a warp produces 16 output columns at once. The fragments need no
staging because the weight goes in the mma A slot and the activation in the mma B
slot: B is [N, K] row-major, exactly the A-row-major fragment, and A is [M, K]
row-major, exactly the B-col-major fragment. The K axis is then permuted so each
lane's loads are contiguous, which is legal because K is a reduction axis and the
same permutation applies to both operands.

Since the mma sums across the four lanes holding different 16-element scale
blocks, the accumulator cannot be flushed per block; the E4M3 scale is folded
into the decoded weight before the mma instead. That is exact in FP16 and BF16
(E2M1 carries 2 significand bits and E4M3 carries 4, so the product needs at most
6) and cannot overflow (max 6 * 448 = 2688, min 0.5 * 2^-9 = 2^-10).

KSplit warps per block take a strided share of the K windows and reduce through
shared memory, without which the 16x drop in warp count costs more on the small
MLP shapes than the extra reuse buys.

Measured on H200 (132 SMs, M = 4, FP16), scalar -> tensor core:

  lm_head       N = 248320, K = 2048   537.6 -> 108.3 us   4.96x
  gate_up_proj  N =    512, K = 2048     3.48 ->  2.99 us  1.17x
  down_proj     N =   2048, K =  512     3.32 ->  2.52 us  1.32x

Over a Qwen3.6 NVFP4 MTP decode step this is 0.949 -> 0.448 ms/step for the FP4
GEMV family and 9.54 -> 8.99 ms/step end to end.

The fp32 accumulation order differs from the scalar path, so results are not
bit-identical to it, but max relative error against an fp64 reference is
unchanged (2.4e-04 .. 3.5e-04, i.e. NVFP4 quantization noise). Set
ORT_FP4_GEMV_MMA=0 to fall back.

The new tests vary the weight, the block scales and the activation along K, so an
inconsistent k mapping cannot pass; the existing GEMV tests use K = 32/64 and
never reach this path.
@tianleiwu
tianleiwu force-pushed the tlwu/20260730/block_scaled_gemv_decode branch from eb5b749 to f545074 Compare July 30, 2026 18:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes the CUDA decode-GEMV fast paths for the block-scaled FP4/FP8 MatMul contrib ops by adding tensor-core (mma.m16n8k16) variants, improving FP4 decode throughput, and adding M-tiling for speculative decode (M>1). It also adds targeted CUDA tests and updates the operator docs to describe the new dispatch behavior and tuning knobs.

Changes:

  • FP8: hoist widening work for M>1 tiles and add an SM80+ tensor-core GEMV path gated by ORT_FP8_GEMV_MMA.
  • FP4: implement prmt-based quad decode, add grid-gated row tiling (ORT_FP4_GEMV_ROW_TILING), and add an SM80+ tensor-core GEMV path gated by ORT_FP4_GEMV_MMA.
  • Add new CUDA unit tests for speculative-decode tiling and tensor-core dispatch; update FP4/FP8 operator documentation and experiment notes.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
onnxruntime/test/contrib_ops/matmul_block_scaled_fp8_test.cc Adds coverage for M>1 speculative decode dispatch and the FP8 tensor-core GEMV path (FP16/BF16, ragged tails, bias).
onnxruntime/test/contrib_ops/matmul_block_scaled_fp4_test.cc Adds coverage for FP4 row-tiling GEMV and the FP4 tensor-core GEMV path (FP16/BF16, ragged tails, bias).
onnxruntime/contrib_ops/cuda/math/matmul_block_scaled_fp8.h Extends GEMV launcher signature to accept sm_major and documents the SM80+ tensor-core selection.
onnxruntime/contrib_ops/cuda/math/matmul_block_scaled_fp8.cu Implements FP8 M>1 hoisted-widening and introduces an SM80+ mma.m16n8k16 GEMV kernel plus dispatch gating.
onnxruntime/contrib_ops/cuda/math/matmul_block_scaled_fp8.cc Passes device sm_major into the FP8 GEMV launcher.
onnxruntime/contrib_ops/cuda/math/matmul_block_scaled_fp4.h Extends GEMV launcher signature to accept sm_major and documents the SM80+ tensor-core sub-path.
onnxruntime/contrib_ops/cuda/math/matmul_block_scaled_fp4.cu Implements FP4 prmt quad decode, row tiling logic, and an SM80+ tensor-core GEMV kernel plus dispatch gating.
onnxruntime/contrib_ops/cuda/math/matmul_block_scaled_fp4.cc Passes device sm_major into the FP4 GEMV launcher.
docs/contrib_ops/cuda/matmul_block_scaled_fp8.md Documents the new FP8 tensor-core GEMV sub-path and kill-switch.
docs/contrib_ops/cuda/matmul_block_scaled_fp8_experiments.md Adds a new “Decode GEMV - Tensor Cores” section and renumbers subsequent sections.
docs/contrib_ops/cuda/matmul_block_scaled_fp4.md Updates scalar GEMV description and documents row tiling + tensor-core GEMV sub-path + new env vars.

Comment thread onnxruntime/contrib_ops/cuda/math/matmul_block_scaled_fp4.cu
Comment thread onnxruntime/contrib_ops/cuda/math/matmul_block_scaled_fp8.cu
Comment thread onnxruntime/contrib_ops/cuda/math/matmul_block_scaled_fp4.cu
Comment thread onnxruntime/test/contrib_ops/matmul_block_scaled_fp8_test.cc
Comment thread onnxruntime/contrib_ops/cuda/math/matmul_block_scaled_fp4.cu
Comment thread onnxruntime/contrib_ops/cuda/math/matmul_block_scaled_fp8.cu
Address PR review on the FP4/FP8 tensor-core decode GEMV: document the exact
per-lane m16n8k16 fragment ownership (A rows -> output columns, B column ->
activation row, D -> output rows 2t/2t+1) in both kernels and both operator
docs, and add a one-hot lane-ownership probe test per data type so any swap of
the row/column mapping fails immediately instead of cancelling out.

Also make the FP8 tensor-core launch gate check M <= 8 explicitly, matching the
FP4 gate, so the structural bound is enforced where it is documented.
@titaiwangms

Copy link
Copy Markdown
Contributor

Automated multi-agent review of PR #31155

Five independent review passes (readability, correctness/idiom, adversarial/critical, deep spec, cross-module integration) were run against this diff. Summary below, deduplicated and prioritized. Items requiring GPU execution to confirm are flagged [needs-run] rather than asserted as verdicts.

Critical / Major

  1. Env var kill switches parse as int, not boolORT_FP4_GEMV_MMA=false silently no-ops (Major, contract drift)
    matmul_block_scaled_fp4.cc:910-911,943-944, matmul_block_scaled_fp8.cc:1507. These call ParseEnvironmentVariableWithDefault<int>("ORT_FP4_GEMV_MMA", 1) == 1. Passing "false" fails std::from_chars, silently falls back to the default (1), so the switch stays enabled despite the user's intent to disable it. The repo has a bool overload used elsewhere (e.g. sparse_attention.cc:58) that accepts "0"/"1"/"true"/"false" — recommend switching to ParseEnvironmentVariableWithDefault<bool>(..., true) for consistency and to avoid a silently-ignored opt-out.

  2. FP8 tensor-core scale-folding may diverge from the FMA path under extreme finite scales (Major, numerical correctness — needs verification)
    matmul_block_scaled_fp8.cu:524-583. The scalar path scales each 16-element lane partial before the warp reduction; the mma path accumulates a full scale block first, scaling afterward. This isn't distributive under IEEE-754 rounding/overflow. With weight_scale near FLT_MAX and opposing large-magnitude FP8 weights (e.g. +448/-448) in different lane slices, the old path can produce +Inf + -Inf → NaN while the new path cancels before scaling and returns a finite/zero result.
    [needs-run: FP8 MMA can return finite/zero where the FMA path returns NaN for cancelling products at extreme finite scale; repro=add a case with M=1,N=1,K=256,block_size=64, fp16 activation=65504, opposing +448/-448 FP8 lane slices, weight_scale=FLT_MAX, run once with default (MMA on) and once with ORT_FP8_GEMV_MMA=0; expect=NaN-vs-finite or identical outputs; cost=cheap]
    Note: the "bit-identical" claims for optimizations Set up CI with Azure Pipelines #1 (hoisted fp32 widening) and update HighLevelDesign.md #3 (row tiling) were independently re-derived and verified correct by the deep-spec pass for their claimed scope (accumulation sequence unchanged) — this finding is specifically about the separate tensor-core scale-folding path, not those two claims.

  3. __launch_bounds__ may be unsatisfiable on SM75 (Major, portability — needs verification)
    matmul_block_scaled_fp4.cu (GemvMinBlocksPerSm, ~L88, applied ~L215): maxThreadsPerBlock(256) × minBlocksPerSM(6) = 1536 threads/SM, which exceeds Turing SM75's 1024-thread/SM limit. The file is only gated on CUDA_VERSION >= 12080, not on target SM, so it is compiled for sm_75 if that's in CMAKE_CUDA_ARCHITECTURES.
    [needs-run: launch bounds compile cleanly (no ptxas error, 0 spill) for sm_75 and sm_86; repro=nvcc -arch=sm_75 -c matmul_block_scaled_fp4.cu -Xptxas -v (repeat for sm_86); expect=no error, spill=0; cost=cheap]

  4. New tests don't exercise the kill-switch fallback paths or boundary guards (Major, test coverage)
    matmul_block_scaled_fp4_test.cc, matmul_block_scaled_fp8_test.cc. New tests cover the "happy path" tensor-core/tiled shapes well (and the GemvTensorCoreLaneOwnership* tests are a genuinely strong adversarial design — one-hot activations that can't cancel a wrong lane→(row,col) mapping). But none flip ORT_FP4_GEMV_MMA=0 / ORT_FP8_GEMV_MMA=0 / ORT_FP4_GEMV_ROW_TILING=0 to verify parity with the scalar fallback, and none test just past the hard guard boundaries (M=9, K=192 for K%128==0/K%64==0 gates). Recommend adding ScopedEnvironmentVariables-based A/B tests comparing MMA-on vs MMA-off output on realistic (non-exact) quantized data with a relative tolerance — current tests use inputs chosen so the fp32 reference is exact, which is good for index-mapping bugs but blind to accumulation-order/accuracy regressions.
    [needs-run: MMA path matches scalar path within NVFP4/E4M3 quantization tolerance on random data across the kill switches; repro=onnxruntime_test_all --gtest_filter=*MatMulBlockQuantized*Gemv* with ORT_FP4_GEMV_MMA/ORT_FP8_GEMV_MMA/ORT_FP4_GEMV_ROW_TILING each at 1 and 0, M∈{1,2,3,4,8,9}, K∈{128,192,256,512}; expect=identical pass/fail set, max relative diff within expected quantization noise; cost=cheap]

Minor

  • Process-wide SM-count caching breaks on heterogeneous multi-GPUmatmul_block_scaled_fp4.cu, Fp4GemvRowsPerBlock and PickFp4MmaConfig both stash getMultiProcessorCount() in a function-static, so whichever GPU calls first fixes the tiling heuristic for every other GPU in the process. Pass the device's SM count through explicitly instead.
  • Inconsistent sm_major derivation between FP4 and FP8 call sitesmatmul_block_scaled_fp4.cc:196 uses sm_ / 10, matmul_block_scaled_fp8.cc:119 uses GetDeviceProp().major. Same quantity, different derivation; prefer one convention (GetDeviceProp().major) in both, and mirror the doc wording between the two header declarations.
  • Row-tiling optimization is largely shadowed by the new mma path — the mma branch is taken first whenever sm_major>=8 && K%128==0 && M<=8, which covers essentially all production shapes, so RowsPerBlock>1 mostly only fires pre-SM80 or when K isn't a multiple of 128. The design doc presents both as independently active without noting this; worth a sentence.
  • Unclaimed summation-order change in the FP8 FMA dispatch itself — the <RowsPerWarp,ColsPerWarp,Unroll> selection changed for M∈[2,4] (e.g. <2,1,1><2,4,1>/<2,2,1>/<2,1,2>), and since koff[u] = base + u*kStride + lane*16, changing Unroll changes each lane's K-chunk accumulation order — a legitimate but unmentioned last-ulp change beyond the documented hoisting optimization.
  • Stray duplicated section banner in matmul_block_scaled_fp4.cu (~L361) where the new row-tiling comment block and the pre-existing FP4 decode comment block now abut without a blank line — looks like a merge artifact.
  • __launch_bounds__/kernel signature formatting diverges from the sibling kernel's layout in the same file (matmul_block_scaled_fp4.cu:508 vs the adjacent Mma kernel) — mostly cosmetic, but harder to scan.

Open questions (not blocking, for maintainer/CI follow-up)

  • If ORT ships a binary built only up to sm_70 but a host sm_major>=8 gate could theoretically pass via forward-compat/JIT on an SM80 device, would the compiled (no-op-for-old-arch) mma kernel silently accumulate zeros? Please confirm this combination isn't reachable in ORT's packaging.
  • Is the FP4 mma path also a net win at M=1 for small-N shapes (e.g. N=512), where most of the mma's N-extent would be idle? Only M=4 numbers are shown for FP4; the FP8 doc has M=1 numbers.
  • The Fp4GemvRowsPerBlock gate (col_blocks >= sm_count) means a future GPU with more SMs than today's test shapes assume would silently fall back to RowsPerBlock==1 in the row-tiling tests without failing — losing coverage silently. Worth a forced-tiling test hook symmetric with the =0 kill switch?

Praise (call these out, worth keeping)

  • The mma.m16n8k16 fragment-ownership tables and comments in both .cu files were independently re-derived from the PTX ISA per-thread layout and verified correct, including the easy-to-botch g/t asymmetry (loads keyed off g, stores keyed off t).
  • The E2M1 quad-decode via prmt.b32 was verified bit-exact against the prior per-code decode for all 16 codes, both fp16 and bf16.
  • The "bit-identical" claims for the hoisted-widening and row-tiling optimizations were verified correct for their stated scope by independent derivation of the accumulation sequence.
  • GemvTensorCoreLaneOwnership* tests are a well-designed adversarial instrument (one-hot inputs that can't cancel a wrong lane mapping).
  • Design docs explain the why behind each trick (operand-swap rationale, K-permutation legality, register budget) rather than just the what — a good template for future perf PRs in this codebase.

Posted by an automated multi-agent review (5 reviewer passes). Findings with [needs-run] are hypotheses requiring GPU execution to confirm/refute — not yet verified.

@titaiwangms

Copy link
Copy Markdown
Contributor

Addendum: hardware-verified findings (from a second deep-spec pass)

A follow-up deep-spec pass actually executed verification code on real SM80 hardware (not just static analysis) and turned up a more serious test-coverage gap than my first pass surfaced. Posting since it's concrete and actionable:

Major — FP8 tensor-core tests are numerically vacuous; an all-zeros kernel would pass

onnxruntime/test/contrib_ops/matmul_block_scaled_fp8_test.ccGemvTensorCoreTilesFp16 / GemvTensorCoreTilesBf16.

The chosen value pattern (weights period-3, activations period-4, small scales) produces near-total cancellation, so max|expected| across every case is far smaller than the configured tolerance:

case (n, k, block_size) max|expected| abs tolerance all-zeros output passes?
18, 256, 64 0.041016 0.05 yes
1026, 320, 64 0.044922 0.05 yes
4098, 1024, 256 0.041016 0.05 yes
8194, 1024, 512 0.021484 0.05 yes

SetOutputTolerance(abs_error, rel_error=-1.0f) sets an absolute-only tolerance here, and it exceeds the entire signal. The bf16 variant is worse (tol=0.1 vs |acc|≤0.045 with an integer bias in [-2,2] — a kernel returning only the bias would pass). By contrast, GemvTensorCoreLaneOwnershipFp16 (one-hot activations, tol 0.01) and the FP4 mma tests (max|expected| 360–2784 vs tol 0.5) are correctly constructed — this is specifically an FP8-suite gap. Since this PR states the mma path "shares no code with the FMA kernel," this is the highest-risk new code with the weakest test oracle. One-line fix: drop the scale divisor (e.g. /256.0/4.0) or tighten to SetOutputTolerance(1e-3f).

Minor — doc self-contradiction on FP8 mma accuracy

docs/contrib_ops/cuda/matmul_block_scaled_fp8.md §4.1 says "accuracy is unchanged," while matmul_block_scaled_fp8_experiments.md §6.2 says "not bit-identical to the FMA kernel (different summation order)." The FP4 doc gets this right elsewhere ("this path is not bit-identical to it"). Worth aligning, since existing M==1 FP8 users on SM80+ (K≥256, K%64==0, block_size%64==0) silently move from the FMA kernel to the mma kernel by default.

Confirmed on hardware (positive results, reducing risk elsewhere)

The same pass fetched the PTX ISA mma.m16n8k16 fragment spec and OCP Microscaling Format spec, then wrote independent kernels from the spec and ran them on an A100 to cross-check the PR's claims — not just re-reading the PR's own code:

  • mma fragment mapping (weight in A-slot, activation in B-slot): reproduced independently, 0 mismatches, 0.0 worst-case diff over a 16×8 tile.
  • E2M1 quad-decode via prmt.b32: bit-identical to the old per-code decode across all 256 packed byte values, both fp16 and bf16 — 0 mismatches.
  • FP8 hoisted-widening and FP4 row-tiling "bit-identical" claims: confirmed at the source level (identical fmaf operand/order sequence); flagged as an open caveat that this is source-level, not ISA-level, since nvcc's FMA-contraction choice can differ per template instantiation — [needs-run: RowsPerBlock∈{1,2,4} produce bit-identical Y; repro=full ORT CUDA build + gtest on sm_80 comparing ORT_FP4_GEMV_ROW_TILING=0 vs =1 byte-for-byte; cost=expensive].

Two smaller coverage gaps also found: the RowsPerWarp=8 FP8 branch and the FP4 lo_ok==false predicate (n mod 16 ∈[1,8]) are both currently untested by the new test shapes.

- Parse the GEMV kill switches as bool so ORT_FP4_GEMV_MMA=false and friends
  actually disable the path instead of silently falling back to the default.
- Pass the device properties into the GEMV launchers instead of caching the
  multiprocessor count in a function-static, which fixed the tiling heuristic
  to whichever GPU ran first in a heterogeneous multi-GPU process. This also
  unifies the sm_major derivation between the FP4 and FP8 call sites.
- Cap __launch_bounds__ minBlocksPerSM at 4 for pre-SM80, where 1024 resident
  threads per SM makes 5/6 blocks of 256 unsatisfiable.
- Raise the FP8 tensor-core test scales so |expected| is far above the output
  tolerance; the previous 1/256 scales left an all-zeros output passing.
- Cover the FP8 RowsPerWarp = 8 FMA tile (M in [5, 8]) and the FP4 mma
  lo_ok == false predication (N = 36).
- Align the docs: the FP8 mma path is not bit-identical to the FMA path, the
  M > 1 FMA dispatch re-tune changes the summation order, and FP4 row tiling
  is superseded by the mma path on SM80+ when K % 128 == 0.
@tianleiwu

Copy link
Copy Markdown
Contributor Author

Thanks for the very thorough review (both the initial pass and the hardware-verified addendum). Pushed 1ea28c0 which addresses all the actionable findings. Summary below, including two items I'd like to push back on.

Fixed

Env kill switches were parsed as intORT_FP4_GEMV_MMA=false went through std::from_chars, failed, and silently returned the default 1, so the switch only worked with 0. All three (ORT_FP4_GEMV_MMA, ORT_FP4_GEMV_ROW_TILING, ORT_FP8_GEMV_MMA) now use ParseEnvironmentVariableWithDefault<bool>, which accepts 0/1/true/false/True/False and hard-errors on anything else instead of silently ignoring it. Verified on A100: the whole suite passes with =false and with =0, and both actually take the fallback path now.

__launch_bounds__(256, 6) is unsatisfiable on SM75 — 6 x 256 = 1536 > the 1024 resident threads/SM limit, so ptxas over-restricts registers and spills for nothing. Added an arch-aware cap:

#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ < 800
constexpr int kGemvMaxBlocksPerSm = 4;   // 1024 threads/SM = 4 blocks of 256
#else
constexpr int kGemvMaxBlocksPerSm = 8;
#endif

and GemvMinBlocksPerSm<RowsPerBlock>::value is now min(target, kGemvMaxBlocksPerSm).

Process-wide SM-count cachingstatic int const sm_count = getMultiProcessorCount(); inside Fp4GemvRowsPerBlock / PickFp4MmaConfig froze the heuristic to whichever GPU happened to run first, which is wrong in a heterogeneous multi-GPU process. sm_count is now a parameter, and the launchers take const cudaDeviceProp& instead of int sm_major. That also fixes the second minor point: the FP4 call site derived the arch as sm_ / 10 while FP8 used GetDeviceProp().major — both now just pass GetDeviceProp().

FP8 tensor-core tests were numerically vacuous — you were right, and thanks for actually computing it. With block scales of (1 + i % 3) / 256.0f, max|expected| was ~0.021-0.045, well under the SetOutputTolerance(0.05f) / 0.1f abs tolerances, so a kernel returning all zeros would have passed. Changed the divisor to 4.0f and tightened the tolerances to 0.005f (fp16) / 0.02f (bf16). max|expected| is now 1.4-2.9 across the four shapes, i.e. 275x+ the tolerance. The values stay exactly representable (all products are multiples of 1/8), so the tightened tolerance is not flaky — verified passing on A100.

Untested branches — added m in {5, 8} to GemvSpeculativeDecodeTilesFp16 to cover the <8, 1, 1> RowsPerWarp = 8 tile (k = 64 / block_size = 32 keeps the mma path off), and added {36, 128} to kMmaShapes in the FP4 test. N = 36 gives r = N mod 16 = 4 < 8, which is the only way to exercise lo_ok == false; the existing N = 40 only ever hits hi_ok == false.

Docs — three corrections:

  • matmul_block_scaled_fp8.md 4.1 claimed the mma path is accuracy-neutral. Every individual product is still exact, but the summation order differs, so the result is not bit-identical to the FMA path. Reworded, and noted that the mma path is preferred by default on SM80+ including at M == 1.
  • matmul_block_scaled_fp8_experiments.md 5.3 now carries a note that the dispatch table is superseded for M > 1 by the new 6.4, which documents the re-tuned <RowsPerWarp, ColsPerWarp, Unroll> table, the H200 numbers, and the last-ulp summation-order caveat.
  • matmul_block_scaled_fp4.md now states that the tensor-core sub-path takes precedence on SM80+ whenever K % 128 == 0, so row tiling is what actually runs on pre-SM80 devices or when K is not a multiple of 128. Same note added above the kernel.

Also removed the now-unused cuda_runtime_utils.h include, fixed the duplicated section banner, and reformatted the scalar GEMV signature to match the sibling mma kernel.

Pushing back

FP8 mma scale folding under FLT_MAX weight scales. The concern is that folding the scale into the operand can produce +Inf and -Inf partials whose sum is NaN, where the FMA path would not. That divergence is real in principle, but the input is already out of contract: with a FLT_MAX block scale the FMA path overflows to Inf in its own accumulator too, so neither path returns a usable number. Guarding it would cost a per-element finite check in the inner loop of the decode hot path to protect a case that cannot arise from any calibrated quantizer. I've documented that the two paths are not bit-identical rather than adding the guard; happy to revisit if you'd prefer an explicit clamp.

Tests that flip the kill switches for in-process A/B parity. I'd rather not do this in-process: each switch is read into a function-local static bool const, so the value is latched on first use and a mid-run ScopedEnvironmentVariables toggle would be order-dependent and silently no-op depending on which test ran first. Making them re-read per call would put a getenv on the decode hot path. Instead I ran the full suite three ways (default, =false, =0) as a process-level A/B — all 22 tests pass in each configuration against the same reference — and widened the deterministic in-suite coverage (RowsPerWarp = 8, lo_ok == false, and the now non-vacuous tensor-core oracles).

Verification

onnxruntime_provider_test --gtest_filter='MatMulBlockQuantizedFp8WeightOpTest.*:MatMulBlockQuantizedFp4WeightOpTest.*' on A100 (SM80), CUDA 13.0: 22/22 pass in all three env configurations.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can commit the suggested changes from lintrunner.

Comment thread onnxruntime/contrib_ops/cuda/math/matmul_block_scaled_fp4.cu Outdated
@tianleiwu
tianleiwu enabled auto-merge (squash) August 1, 2026 01:15
@tianleiwu
tianleiwu merged commit 86af0f1 into main Aug 1, 2026
87 checks passed
@tianleiwu
tianleiwu deleted the tlwu/20260730/block_scaled_gemv_decode branch August 1, 2026 02:27
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.

4 participants