docs/contrib_ops/cuda/matmul_block_scaled_fp8_experiments.md
This document records CUDA performance experiments for
MatMulBlockQuantizedFp8Weight (com.microsoft::MatMulBlockQuantizedFp8Weight).
It includes retained optimizations and their measured effect so future tuning
does not repeat work whose results are already known.
Related documentation:
build/cu130/Release.N=4096, K=4096, block_size=128.A, FP8 E4M3 weight B, FP32 per-block scales, FP16 output.CUDA_VISIBLE_DEVICES=0.The operator is weight-only: A stays FP16/BF16, B is dequantized to the
activation type, and the product runs either as a fused decode GEMV (small M)
or as a cuBLAS GEMM on the dequantized weight (larger M). There is no native
FP8 block-scaled tensor-core path; the design is architecture independent
(SM80+).
Measured latency of the two dispatch paths at N=4096, K=4096, fp16 before the
dequantization kernel was optimized:
| M | Path | Mean latency | Notes |
|---|---|---|---|
| 1 | decode GEMV | 0.076 ms | one warp per output column, row group 1 |
| 2 | decode GEMV | 0.097 ms | row group 2 |
| 4 | decode GEMV | 0.123 ms | row group 4 |
| 8 | decode GEMV | 0.132 ms | row group 8 |
| 16 | dequant + cuBLAS | 0.396 ms | dequant dominated |
| 32 | dequant + cuBLAS | 0.396 ms | dequant dominated |
| 64 | dequant + cuBLAS | 0.399 ms | dequant dominated |
| 128 | dequant + cuBLAS | 0.453 ms | dequant + growing GEMM |
| 256 | dequant + cuBLAS | 0.596 ms | GEMM growing |
| 512 | dequant + cuBLAS | 0.732 ms | GEMM significant |
The flat 0.396-0.399 ms across M = 16, 32, 64 is the tell: the prefill cost
does not depend on M there, so the GEMM is negligible and a fixed per-shape
cost dominates.
The default prefill path expands the whole [N, K] weight into an FP16/BF16
scratch buffer before the cuBLAS GEMM. That expansion moves N*K bytes in
(FP8) and 2*N*K bytes out (FP16/BF16), independent of M. To confirm this is
the bottleneck, prefill latency at M=16 was measured while varying N*K:
| N | K | N*K | Mean latency |
|---|---|---|---|
| 4096 | 2048 | 8.4 M | 0.203 ms |
| 2048 | 4096 | 8.4 M | 0.194 ms |
| 4096 | 4096 | 16.8 M | 0.399 ms |
| 4096 | 8192 | 33.6 M | 0.730 ms |
| 8192 | 4096 | 33.6 M | 0.733 ms |
Latency scales linearly with N*K and is independent of whether N or K
grows, which matches a memory-bound dequantization and rules out the GEMM as the
prefill bottleneck. At 16.8 M elements in 0.40 ms the effective traffic is only
about 125 GB/s, roughly 28% of the 448 GB/s peak.
The original kernel DequantizeBlockScaledFp8Kernel mapped one thread to one
output element and computed, per element:
const int row = static_cast<int>(idx / k); // 64-bit division per element
const int col = static_cast<int>(idx - (long long)row * k);
const int blk = col / block_size;
out[idx] = FromFloat<T>(static_cast<float>(b_fp8[idx]) * weight_scale[row * k_blocks + blk]);
Two problems limited bandwidth: a 64-bit idx / k division on every element, and
scalar 1-byte loads / 2-byte stores that do not form wide coalesced memory
transactions.
DequantizeBlockScaledFp8Vec16Kernel replaces the scalar kernel when
K % 16 == 0 (the common layout; the scalar kernel is kept for K % 16 != 0).
Each thread converts one aligned 16-element K chunk of a single row:
uint4; the 16 FP16/BF16 results store as a 32-byte pair of uint4. Because
K % 16 == 0 every row begins 16-byte aligned, so both accesses are aligned
and 32 threads of a warp cover 512 contiguous bytes.row = blockIdx.y, with a grid-stride loop when N exceeds 65535), so the
expensive idx / k division is gone entirely.block_size % 16 == 0, all 16 elements of a
chunk fall in the same K block, so the kernel loads a single b_scale value
per chunk instead of one per element. The general case still does a per-element
block lookup.Measured effect at N=4096, K=4096, fp16:
| M | Before | After | Speedup |
|---|---|---|---|
| 16 | 0.396 ms | 0.262 ms | 1.51x |
| 32 | 0.396 ms | 0.260 ms | 1.52x |
| 64 | 0.399 ms | 0.261 ms | 1.53x |
| 128 | 0.453 ms | 0.303 ms | 1.50x |
| 256 | 0.596 ms | 0.392 ms | 1.52x |
| 512 | 0.732 ms | 0.584 ms | 1.25x |
Larger weight, N=11008, K=4096, fp16:
| M | Before | After | Speedup |
|---|---|---|---|
| 32 | 0.970 ms | 0.662 ms | 1.47x |
| 128 | 1.005 ms | 0.713 ms | 1.41x |
Effective dequantization bandwidth (counting 3 bytes per element: 1 read + 2
write) after the change, from the N*K scaling sweep at M=16:
| N*K | Mean latency | Effective bandwidth |
|---|---|---|
| 8.4 M | 0.133 ms | about 189 GB/s |
| 16.8 M | 0.258 ms | about 195 GB/s |
| 33.6 M | 0.484 ms | about 208 GB/s |
Bandwidth rose from about 125 GB/s to about 195-208 GB/s (about 1.55x), and the marginal throughput between the smallest and largest sweep points is about 215 GB/s. Accuracy is unchanged: the focused C++ tests and the Python harness accuracy checks pass at every measured shape.
This optimization is kept.
An earlier round of this document concluded the decode GEMV was "already well tuned and not the optimization target". That conclusion came from measurements taken through the ORT Python API without CUDA graphs, which on a fast GPU are dominated by per-node host overhead rather than by the kernel. Re-measuring on H200 (SM90) with the launches captured in a CUDA graph changed the picture.
At N = 8192, K = 2048, M = 1 the op-level measurement reported 21.0 us while
the kernel itself takes 7.2 us. The difference is ORT per-node host work; the
host, not the GPU, was the limiter in that harness. Two rules follow:
ncu --section SpeedOfLight --section MemoryWorkloadAnalysis --section Occupancy --section WarpStateStats on the original kernel at N = 8192, K = 2048, M = 1:
| Metric | Value | Reading |
|---|---|---|
| DRAM throughput | 35.3% | not bandwidth bound |
| Compute (SM) throughput | 52.3% | not compute bound either |
| Waves | 1.0 | grid barely fills the GPU once |
| L1/TEX hit rate | 79.6% | the A row is already resident in L1 |
| L2 hit rate | 5.2% | A re-reads never reach L2 |
| Block limit (registers) | 8 blocks/SM | occupancy already register capped |
| Achieved occupancy | 71.0% | vs 100% theoretical |
| Warp cycles / issued instr | 16.7, of which 5.5 on L1TEX | latency bound |
The kernel is short of outstanding loads, not of bandwidth or instructions.
Each thread moves only K / 32 = 64 bytes of B, and because k is a runtime
value the K loop does not unroll, so a thread has exactly one B load in flight
and pays full L1 latency every iteration.
Two hypotheses were tested and rejected:
static_cast<float>
FP8 converts with __nv_cvt_fp8x2_to_halfraw2 (one cvt.rn.f16x2.e4m3x2 per
pair) is bit-exact and worth only 0-15%. Instruction count was not the limit.A in shared memory. The concern was that all N warps re-read the
whole A row. The 79.6% L1 hit rate shows L1 already absorbs this; a shared
memory variant was neutral to slower except at very small N.MatMulBlockScaledFp8GemvKernel is now templated on
<RowsPerWarp, ColsPerWarp, Unroll, AType>, where <R, 1, 1, A> reproduces the
original geometry exactly:
Unroll pre-issues Unroll independent B/A loads before consuming any of
them, so several requests are in flight per thread.ColsPerWarp gives one warp several output columns, so each A load feeds
several independent FMA chains.Both trade occupancy - already register capped, and irrelevant at one wave - for per-thread memory-level parallelism. The FP8 to FP16 conversion is also vectorized. FP32 accumulation is unchanged, so results are bit-identical to the previous kernel.
Dispatch (only M == 1, the batch-1 decode case, uses wide tiles):
| Condition | Config |
|---|---|
M == 1, N >= 8192 | <1, 4, 2> |
M == 1, N >= 4096 | <1, 2, 2> |
| otherwise | <RowsPerWarp, 1, 1> (unchanged) |
Below N = 4096 the wider tiles leave too few warps to fill the GPU, and for
M > 1 the extra live registers (accumulators plus pre-issued loads) cost more
than the added parallelism returns. Both measured slower, hence the guards.
Superseded for
M > 1by section 6.4, which re-tunesColsPerWarp/Unrollfor the speculative-decode tiles. BecauseUnrollchanges the K chunk each lane accumulates first, theM > 1dispatch there is not bit-identical to<RowsPerWarp, 1, 1>(last-ulp only; the accumulation is still FP32).
M = 1, CUDA graph, us, includes 0.68 us node overhead)| Shape (N x K) | cuBLAS FP16 | GEMV before | GEMV after | vs before | vs cuBLAS |
|---|---|---|---|---|---|
| 8192 x 2048 | 10.2 | 7.2 | 5.4 | 1.33x | 1.89x |
| 4096 x 2048 | 7.1 | 4.4 | 4.0 | 1.10x | 1.78x |
| 4096 x 4096 | 9.2 | 7.1 | 6.0 | 1.18x | 1.53x |
| 2048 x 4096 | 7.6 | 4.6 | 4.6 | 1.00x | 1.65x |
| 512 x 2048 | 5.4 | 2.7 | 2.7 | 1.00x | 2.00x |
At 8192 x 2048 this is 3.1 TB/s of the 4.8 TB/s HBM peak, up from 2.3 TB/s.
Note the last column: the weight-only FP8 GEMV is 1.5-2.0x faster than cuBLAS
FP16 at M = 1, so quantizing a projection to FP8 is a decode win on latency
as well as on footprint. At M >= 4 cuBLAS wins and the GEMV path should not be
preferred on speed alone.
Section 5 tuned memory-level parallelism at M = 1. At M = 4 - the width of a
speculative-decode / MTP verify forward - the kernel is limited by something
else. With RowsPerWarp = 4 a lane executes roughly 240 instructions per 32
weight bytes, only 128 of which are the FMAs that do useful work, and effective
bandwidth falls from about 2.35 TB/s at M = 1 to about 1.25 TB/s at M = 4.
More ILP cannot fix that; the dot products have to leave the FMA pipe.
MatMulBlockScaledFp8MmaGemvKernel uses mma.m16n8k16 with FP32 accumulation.
The operand assignment is the key decision:
| mma operand | fed from | why it fits |
|---|---|---|
A[16, 16] row-major | weight [16 output cols][16 k] | B is [N, K] row-major |
B[16, 8] col-major | activation [16 k][8 rows] | A is [M, K] row-major |
D[16, 8] | y[16 output cols][8 rows] |
So the mma "M" extent is the output column count and the mma "N" extent is M.
At M = 4 half the mma N lanes are idle, which is irrelevant: the kernel is
bound by weight traffic and instruction issue, and both improve about 10x per
weight byte.
The naive fragment load is badly coalesced - a lane needs bytes
{2t, 2t+1, 2t+8, 2t+9} of a row, which spreads a warp across 16 rows x 16
bytes and over-fetches every 32-byte sector 2x. The fix is to permute the K
axis. K is a reduction axis, so any permutation applied to both operands
leaves the result unchanged. Inside a 64-element K window the permutation used is
mma k-slot (of step j) -> actual k
2t, 2t+1 16t + 4j, 16t + 4j + 1
2t+8, 2t+9 16t + 4j + 2, 16t + 4j + 3
so lane (g = lane >> 2, t = lane & 3) loads one contiguous uint4 of weight
bytes [16t, 16t + 16) and the matching 32 activation bytes, four lanes cover 64
contiguous bytes of one weight row, and that single uint4 feeds all four mma
steps.
16 columns per warp gives about 8x fewer warps than the FMA kernel, which alone
costs more in lost memory-level parallelism than the instruction saving is worth.
KSplit warps per block therefore take a strided share of the K windows and are
reduced through shared memory at the end. KSplit = 8 for N >= 8192 (the
column count already fills the grid) and 16 otherwise.
Preconditions: SM80+, K % 64 == 0, K >= 256, block_size % 64 == 0, M <= 8.
Otherwise the FMA kernel runs unchanged. ORT_FP8_GEMV_MMA=0 forces the FMA
kernel for A/B testing in a single binary.
Not bit-identical to the FMA kernel (different summation order), but not less accurate either. E4M3 to FP16 is lossless, E4M3 to BF16 is lossless, FP16 x FP16 products are exact in FP32, and the mma accumulates in FP32 exactly as the FMA path does. Scored against an FP64 CPU reference on the shapes below, the maximum error is identical for the two kernels (2-4e-4, i.e. pure FP16 output rounding).
fp8_gemv_m4_bench.cu)| Shape (N x K) | M | cuBLAS FP16 | FMA kernel | mma kernel | vs FMA | vs cuBLAS |
|---|---|---|---|---|---|---|
| 8192 x 2048 | 1 | 11.0 | 6.3 | 5.1 | 1.23x | 2.16x |
| 4096 x 2048 | 1 | 8.4 | 4.8 | 4.0 | 1.20x | 2.09x |
| 2048 x 4096 | 1 | 9.0 | 5.1 | 4.2 | 1.21x | 2.13x |
| 512 x 2048 | 1 | 6.7 | 3.3 | 3.1 | 1.06x | 2.12x |
| 8192 x 2048 | 4 | 11.0 | 9.8 | 5.2 | 1.87x | 2.10x |
| 4096 x 2048 | 4 | 8.5 | 6.9 | 4.1 | 1.69x | 2.09x |
| 2048 x 4096 | 4 | 8.5 | 8.0 | 4.4 | 1.82x | 1.92x |
| 512 x 2048 | 4 | 6.8 | 4.7 | 3.3 | 1.43x | 2.08x |
| 8192 x 2048 | 8 | 10.9 | 17.5 | 5.7 | 3.09x | 1.93x |
| 2048 x 4096 | 8 | 8.5 | 13.0 | 4.5 | 2.90x | 1.90x |
The mma kernel is faster at every measured M, so it is preferred whenever its
preconditions hold rather than only for M > 1. Note also that the FMA kernel
crosses over and loses to cuBLAS at M = 8, while the mma kernel stays about 1.9x
ahead.
End to end on a 40-layer Qwen3.6-35B-A3B NVFP4 MTP decode (130 FP8 matmul nodes
per step, M = 4), CUDA graphs on:
| FMA kernel | mma kernel | |
|---|---|---|
| FP8 GEMV kernel time | 1.052 ms/step | 0.713 ms/step |
| total kernel time | 7.368 ms/step | 7.021 ms/step |
| wall | 9.80 ms/step | 9.54 ms/step |
No other kernel family moved. This optimization is kept.
M > 1The FMA kernel still runs when the tensor-core preconditions do not hold (pre-SM80,
K < 256, K % 64 != 0 or block_size % 64 != 0), so the M > 1 tiles were
re-tuned there as well. Widening A to FP32 is now hoisted out of the column loop
(one widening per row instead of one per row/column pair), which makes ColsPerWarp
profitable at M > 1 for a second reason beyond memory-level parallelism:
| Condition | Config |
|---|---|
2 <= M <= 2, N >= 8192 | <2, 4, 1> |
2 <= M <= 2, N >= 2048 | <2, 2, 1> |
2 <= M <= 2 otherwise | <2, 1, 2> |
3 <= M <= 4, N >= 4096 | <4, 4, 1> |
3 <= M <= 4, N >= 2048 | <4, 2, 2> |
3 <= M <= 4 otherwise | <4, 1, 2> |
M > 4 | <8, 1, 1> |
Measured on H200 (us, M = 4, versus the previous <R, 1, 1> and cuBLAS FP16):
| Shape (N x K) | cuBLAS | <4, 1, 1> | tuned |
|---|---|---|---|
| 8192 x 2048 | 10.9 | 13.7 | 9.7 (<4, 4, 1>) |
| 4096 x 2048 | 8.3 | 8.1 | 6.9 (<4, 4, 1>) |
| 2048 x 4096 | 8.3 | 9.0 | 7.9 (<4, 2, 2>) |
| 512 x 2048 | 7.3 | 5.0 | 4.6 (<4, 1, 2>) |
The hoisting itself is bit-identical (the per-lane fmaf sequence is unchanged),
but a different Unroll changes which K chunk a lane accumulates first, so the
re-tuned dispatch is a last-ulp change relative to <RowsPerWarp, 1, 1>.
The commands below use ORT_REPO and ORT_BUILD so they can be copied without
editing developer-specific paths. Set them once:
export ORT_REPO=$(git rev-parse --show-toplevel)
export ORT_BUILD="$ORT_REPO/build/cu130/Release"
Provider rebuild and Python-provider sync after editing the .cu kernel:
cmake --build "$ORT_BUILD" --target onnxruntime_providers_cuda --parallel
cp "$ORT_BUILD/libonnxruntime_providers_cuda.so" \
"$ORT_BUILD/onnxruntime/capi/libonnxruntime_providers_cuda.so"
Decode GEMV benchmarks (small M):
cd /tmp && PYTHONPATH="$ORT_BUILD" CUDA_VISIBLE_DEVICES=0 \
python "$ORT_REPO/onnxruntime/test/python/contrib_ops/profile_matmul_block_scaled.py" \
--op fp8 --activation-dtype fp16 --m 1 --n 4096 --k 4096 --warmup 100 --repeat 500
Default prefill (dequantize + cuBLAS):
cd /tmp && PYTHONPATH="$ORT_BUILD" CUDA_VISIBLE_DEVICES=0 \
python "$ORT_REPO/onnxruntime/test/python/contrib_ops/profile_matmul_block_scaled.py" \
--op fp8 --activation-dtype fp16 --m 32 --n 4096 --k 4096 --warmup 50 --repeat 200
N*K scaling sweep used to isolate dequantization cost:
for shape in "4096 2048" "4096 4096" "4096 8192" "2048 4096" "8192 4096"; do
set -- $shape
cd /tmp && PYTHONPATH="$ORT_BUILD" CUDA_VISIBLE_DEVICES=0 \
python "$ORT_REPO/onnxruntime/test/python/contrib_ops/profile_matmul_block_scaled.py" \
--op fp8 --activation-dtype fp16 --m 16 --n $1 --k $2 --warmup 50 --repeat 300
done
Focused C++ tests:
CUDA_VISIBLE_DEVICES=0 "$ORT_BUILD/onnxruntime_provider_test" \
--gtest_filter='MatMulBlockQuantizedFp8WeightOpTest.*'
N*K and is independent of M.N*K
here) rather than the FLOP-bearing dimension (M). The flat latency across
M = 16..64 and the linear N*K scaling both pointed at dequantization.uint4 loads/stores plus a
2D grid that removes per-element integer division recovered about 1.55x and
lifted effective bandwidth from about 28% to about 46% of peak.K % 16 != 0; the
vectorized kernel requires the 16-element alignment that K % 16 == 0
guarantees.8192 x 2048, M = 1 that harness reported 21.0 us for a 7.2 us kernel; the
measurement was host bound and led to the wrong conclusion that the decode
GEMV was fine and that FP8 was slower than FP16.__half2 (&)[8]) to a __device__ helper.
It is placed in local memory; inlining the same code via a macro was about 2x
faster here and much more at RowsPerWarp > 1.M = 1, so
quantizing a projection is a decode latency win, not just a footprint win. The
ordering reverses by M = 4.