Repository navigation
[CUDA] Add an SM120 W4A16 NVFP4 GEMM example for decode-sized batches - #3290
Draft
ghostrider0470 wants to merge 1 commit into
Draft
ghostrider0470 wants to merge 1 commit into
ghostrider0470 wants to merge 1 commit into
Conversation
Adds a BF16-activation x NVFP4-weight GEMM for SM120 aimed at LLM decoding
(M = 1..1024 rows), complementing the W4A4 block-scaled path. It only adds
files under examples/, maint/ and testing/; no library changes.
* examples/gemm_sm120/sm120_nvfp4_w4a16_gemm.py (non-persistent kernels)
- prepare_nvfp4_weight(): re-lays out checkpoint tensors (packed E2M1
(N, K/2), E4M3/UE4M3 block scales (N, K/16), per-tensor or per-column
alpha) once into 32x32 blocks ordered like the mma.m16n8k16 B
fragments. One 128-bit shared load per lane yields a full B fragment;
the nibbles are bit-scattered so dequantization costs ~2 instructions
per weight, and the scales are re-encoded so one mul.rn.bf16x2 per
register is exact. Same size as the checkpoint tensors.
- A fragment kernel (M <= 64 rows per pass) and a T.Pipelined tile
kernel, both with deterministic split-K (fixed-order FP32 reduction,
self-resetting tickets) and CUDA-graph capturable; a shape-independent
get_config() heuristic; nvfp4_w4a16_linear().
* maint/gemm/gemm_sm120/sm120_nvfp4_w4a16_streamk.py: a persistent
stream-K kernel with a cp.async stage loader, per-GPU tuned tables
(keyed by SM count; one 188-SM GPU, a 27B-class model's layer shapes)
and a stream-K heuristic for other shapes.
* maint/gemm/gemm_sm120/benchmark_sm120_nvfp4_w4a16_gemm.py: graph-replay
benchmark with rotated weights (median and fastest of rotated-order
rounds) vs BF16 cuBLAS and, when vLLM is importable, vLLM's Marlin
NVFP4 W4A16 kernel, whose output is checked under --verify.
* testing/python/quantize/test_tilelang_quantize_nvfp4_w4a16_gemm.py:
CPU tests (bit-exact emulation of the register dequantization against
the PTX B-fragment layout, input validation, config divisibility and
shared-memory limits) and SM120-gated GPU tests (elementwise tolerance
against an FP32 reference, explicit configs, zero/negative scales,
chunking, out=, misaligned x, CUDA-graph capture including a first call
inside a shared-pool capture); every tuned-table entry and a wider
shape sweep run under the `slow` marker.
Measured on one RTX PRO 6000 Blackwell Max-Q (188 SMs, sm_120) only.
Layer-weighted over the tuned shapes, the tuned path is 1.2-4.6% faster
than Marlin at M = 1..32, 3-7% faster at M = 72..144 and 2.8-4.8% faster
at M = 384, 512, 768 and 1024, but 0.5-1.9% slower at M = 33, 40, 49,
56, 192, 208, 256, 416 and 832 and 6.0% slower at M = 544. On untuned shapes it
ranges from 0.75x to 1.24x of Marlin's speed.
|
👋 Hi! Thank you for contributing to the TileLang project. Please remember to run We appreciate you taking this step! Our team will review your contribution, and we look forward to your awesome work! 🚀 |
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This branch has not been deployed
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.
Summary
This adds a GEMM with BF16 activations and NVFP4 weights (W4A16) for SM120, aimed at LLM decoding with M = 1 to 1024
rows. It is item 2 of the plan in the comment on #1592. It complements the W4A4 block-scaled path from #3237 and #3257.
That path quantizes activations too, while this one keeps them in BF16. In serving, the small-M decode steps are where
most tokens are generated.
that is prepared once from the checkpoint tensors (packed E2M1, E4M3/UE4M3 block scales, global or per-column
scale).
beats vLLM's Marlin NVFP4 W4A16 kernel by 1.2-4.6% at M = 1-32 and by 3-7% at M = 72-144. It is within ±2% of Marlin
at most larger M, but has one 6% loss (see Benchmarks). It is 2.2-3.5x faster than a BF16 cuBLAS GEMM at M ≤ 32.
Only
examples/,maint/andtesting/change. Nothing undertilelang/changes, and no file overlaps the open PRs#3081, #3099, #2171, #3284 or #3286.
Design
Weight layout.
prepare_nvfp4_weightre-lays out the weight once into blocks of 32 (n) x 32 (k). Each block is512 bytes, ordered like the
mma.m16n8k16B fragments of one warp:lowns 16 consecutive bytes. Oneld.shared.v4per lane per block gives a complete B fragment for two n16tiles x two k16 steps, so no BF16 weight tile is ever written to shared memory.
bf16x2fragment registers need one shiftand one AND each; the fourth needs three shifts and three LOP3s. E2M1 codes land in the BF16 subnormal range as
fp4 * 2^-126.b, so thatbf16(0x7000 | b << 4) == scale * 2^119. This isexact for every finite E4M3 value, subnormals included. One PRMT plus one IMAD turn a scale word into
bf16x2pairs.One
mul.rn.bf16x2per register then givesfp4 * scale * 2^-7exactly, andalpha * 2^7is applied in the FP32epilogue.
N*K/2 + N*K/16bytes.M range and kernels. The kernels cover M = 1 to 1024 rows per launch; larger batches run as 1024-row chunks.
examples/gemm_sm120/sm120_nvfp4_w4a16_gemm.py) has two non-persistent kernels:T.Pipelinedtile kernel for larger M.a fixed order. The tickets reset themselves.
get_configis a shape-independent heuristic.maint/gemm/gemm_sm120/sm120_nvfp4_w4a16_streamk.py) adds three things:G = SMs * cCTAs walk contiguous ranges of the (tile, k-tile) space, and ahand-written cp.async stage loader gives one barrier per k tile.
T.Pipelinedcannot express this loop, becauseeach tile's k range is known only at run time.
model.
nvfp4_w4a16_linearinterface.Why example-first. #1592 has not yet settled whether this belongs in the library or in examples. The #2364 review
asked for small, non-persistent examples with the heavy persistent path in
maint/, and that is how this is split.The public surface is small, so moving it into the library later means moving modules, not redesigning them:
prepare_nvfp4_weight→NVFP4W4A16Weight,nvfp4_w4a16_linear(x, w, config=None, out=None),get_config, and theFragConfig/TileConfig/StreamKConfignamed tuples. The kernels use only public APIs already present at7a5f446f:T.ptx_mma,TensorCoreIntrinEmitter.ldmatrix_a,make_mma_swizzle_layout,T.Kernel(prelude=),T.dynamic,T.Pipelined,T.ptx_commit_group/T.ptx_wait_group, and scalar prim_func arguments withT.assume.How it differs from
examples/dequantize_gemm/example_dequant_gemm_bf16_fp4_hopper.py. That example is W4A16 FP4without block scales. It dequantizes into a shared BF16 tile and targets large M on Hopper. This PR handles NVFP4 with
E4M3 block scales (the checkpoint format), dequantizes register fragments directly, and targets decode-sized M on
SM120. It lives in
examples/gemm_sm120next to the W4A4 block-scaled example because both target the same checkpointsand GPU.
Scope. The instructions used are
mma.sync.m16n8k16(BF16),cp.asyncandmul.rn.bf16x2(sm_90+). Each CTA usesat most 99 KB of shared memory. Tests and tuning ran only on sm_120, so the example, the benchmark and the tests all
require compute capability 12.0.
Changes
examples/gemm_sm120/sm120_nvfp4_w4a16_gemm.py(869 lines): the layout, the register dequant helper(
mul.rn.bf16x2inline PTX), the fragment and tile kernels, the heuristicget_config, andnvfp4_w4a16_linear.dtype, shape, device, contiguity and 16-byte alignment. A misaligned
xis copied.prepare_nvfp4_weight, never lazilyinside a graph capture. A capture that would need to create it raises an error.
--verify. The default timing backend iscupti(see the notes).maint/gemm/gemm_sm120/sm120_nvfp4_w4a16_streamk.py(579 lines): the stream-K kernel, the tuned table keyed by SMcount, the stream-K heuristic (it falls back to the example's tile heuristic when the stream-K ranges would be short),
and
nvfp4_w4a16_linear.maint/gemm/gemm_sm120/benchmark_sm120_nvfp4_w4a16_gemm.py(237 lines): the benchmark.separator kernel whose cost is subtracted.
round; the layer-weighted totals are given for both the fastest and the median rounds.
F.linear) and, when vLLM imports, Marlin (tested with vLLM 0.29.0 internals).--verifychecks both our output and Marlin's against an FP32 reference.--dispatch tuned|example,--layer-counts,--json.testing/python/quantize/test_tilelang_quantize_nvfp4_w4a16_gemm.py(350 lines): 55 tests (36 by default, 19 underslow); details below.Tests
The tests ran on an RTX PRO 6000 Blackwell Max-Q (sm_120, driver 570 + CUDA 13 forward compatibility), with TileLang
built from source at
7a5f446f.test_prepare_layout_matches_mma_b_fragments(CPU)test_prepare_accepts_ue4m3_bytes_and_column_alpha(CPU)test_prepare_rejects_bad_inputs,test_linear_rejects_bad_inputs(CPU)out, non-weight argumentstest_configs_cover_all_rows(CPU)test_tuned_table_is_per_gpu(CPU)test_nvfp4_w4a16_gemmtest_nvfp4_w4a16_gemm_explicit_configtest_nvfp4_w4a16_gemm_zero_negative_scales_chunking_and_outout=; x not 16-byte alignedtest_nvfp4_w4a16_gemm_cuda_graphtest_nvfp4_w4a16_gemm_first_call_in_shared_pool_capturetest_nvfp4_w4a16_gemm_sweep(slow)test_nvfp4_w4a16_gemm_every_tuned_entry(slow)decode_packed_fp4_e2m1anddecode_ue4m3_scale_bytes. It requires:|y - ref| ≤ 2^-8 |ref| + 1e-3 * row_rmson every element;-m slow(3 min 42 s), and all55 passed with a warm cache (17 s). The GPU tests skip on anything other than compute capability 12.0. Upstream CI
therefore runs only the 9 CPU tests.
--verifyreports a relative error of 1.66e-3 at M = 300 on 4096x4096 and at M = 16 on 5120x6144.--verifypassed for both the W4A16 and the Marlin outputs.ruff0.16.6 check and format pass, and codespell is clean.Benchmarks
The benchmark command was
python -m maint.gemm.gemm_sm120.benchmark_sm120_nvfp4_w4a16_gemm --verify --layer-counts 48,16,64,64,64, run on27-09-2026. The setup:
5120x17408. The layer counts give the per-step weight of each shape.
weight_global_scaleis the multiplieralpha, and its output passed--verify.Layer-weighted time per model step, W4A16 (tuned path) vs Marlin and cuBLAS. Negative means W4A16 is faster.
Honest reading of the table:
34816x5120 at M = 33-64, at 0.95-0.96x Marlin.
513-576, and at 544 it picks a tile config that loses 10% on 34816x5120 (822 vs 744 µs).
to about 2-4 points at M ≥ 384.
Selected cells (µs, W4A16 vs Marlin; weight bandwidth at M = 1):
Across all 160 tuned cells, W4A16 is faster in 113. Per cell, Marlin time divided by W4A16 time ranges from 0.905 to
1.185.
Untuned shapes (4096x4096, 11008x4096, 4096x11008, 6144x4096; ratio = Marlin or cuBLAS time / W4A16 time, above 1
means W4A16 is faster):
Notes for reviewers
SM120 W4A16 GEMM op or intrinsics), the modules are ready to move. The split between example and maint follows the
[CUDA] Add SM120 NVF4 block-scale MMA support #2364 feedback.
autotune hook could replace the table if preferred.
tilelang.profiler.do_bench(backend="cudagraph")misreports this call. For example, at M = 384 on 4096x4096 itreports 293 µs, while
cuptireports 76 µs and a graph-replay timer 72 µs. The cause may be the per-calltorch.emptyof the FP32 partials inside do_bench's graph plus its L2 flush; I have not investigated further. Theexample therefore defaults to
cupti. I can open a separate issue with a repro.T.assume. The stream-K grid size is a scalarT.int32argument bound toT.Kernel(G).Without
T.assume(G > 0), every floor division byGgets sign fix-ups, and the kernel ran 1.1-1.5x slower(measured).
_kernel()is deliberate. A dict lookup costs 0.44 µs per call against 0.69 µs for the JITfactory's own cache path. That matters next to a 10-30 µs eager decode GEMM.
example_dequant_gemm_bf16_fp4_hopper.py(see Design) and with[Feature][Blackwell] Add SM120 T.float4_e2m1fn FP4 GEMM support. #2171's
examples/gemm_fp4/example_gemm_a8w4.py(A8W4). No file overlaps an open PR. The NVFP4 reference helpersin
examples/dequantize_gemm/quantize/nvfp4.pyare imported, not edited, which avoids [CUDA] Add SM120 mxf4nvf4 2X/ue8m0 and 4X/ue8m0 block-scale MMA support #3081 and [SM120] kind::mxf8f6f4 block-scaled MMA: full f8f6f4 operand family, sub-byte TMA producers, and official examples #3099.(value + residual) on the native
mxf4nvf4tensor cores.