CUDA: MMQ tile configuration and FP4 accumulation for GB10 (sm_121) - #198
Draft
danielhanchen wants to merge 2 commits into
Draft
CUDA: MMQ tile configuration and FP4 accumulation for GB10 (sm_121)#198danielhanchen wants to merge 2 commits into
danielhanchen wants to merge 2 commits into
Conversation
Dense prompt matmuls on GB10 run the MMQ mainloop at one block per SM: the 128-row tile needs 61.5 KB of shared memory at J=128 and the kernel asks for 256 threads, so nothing can overlap the register-staged global loads or the two barriers per K step. Halving the tile to 64 rows with 128 threads and occupancy 2 makes two blocks resident and is worth 9 to 13 percent on the K-quants at prompt-sized batches. mul_mat_id does not want this: each expert only holds ncols_max*n_expert_used/n_experts columns, so halving the tile height only doubles the number of row tiles that pay for a mostly empty J tile. The tile table therefore keeps the stock 128-row tile in its fallback == true entries and mul_mat_id asks for that config on Blackwell; requesting the bounds-checked variant cannot change results, it only enables the src0->ne[1] range check. The block-scaled FP4 MMA already takes C as an accumulator input, but the vec_dot zeroed a temporary tile per instruction and added it to the running sum afterwards. Accumulating into the sum directly removes 805 FADD and all of the register spilling from the NVFP4 J=128 kernel. Both changes are confined to the Blackwell configuration and to code under BLACKWELL_MMA_AVAILABLE, so no other GPU sees a different kernel.
…re-deriving it mul_mat_q_case re-derived the fallback flag from args.nrows_x % 128, so the flag that ggml_cuda_mul_mat_q computes (and that already sizes the src1 padding through ggml_cuda_mmq_get_J_max) never reached the kernel selection. It matched by construction while the only input was ne01, but any other reason to want the bounds-checked configuration was silently dropped, including the mul_mat_id case added in the previous commit. Carry the decision in mmq_args so the configuration the padding was sized for is the configuration that runs. No behaviour change for the ne01-only rule.
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.
Prompt processing on GB10 (DGX Spark, sm_121a) is matmul bound, and MMQ is the kernel that runs every
quantized
mul_mat. An audit of the sm_121a SASS put the MMQ mainloop at about 31 % of the int8tensor-core peak and 20 to 27 % of the FP4 peak at prompt-sized batches, against 65 to 71 % for
CUTLASS on the same GPU. Two of the causes are cheap to remove, and both are confined to the
Blackwell configuration.
1. The dense tile is one block per SM. At J = 128 the 128-row tile needs 61.5 KB of shared memory
and the kernel asks for 256 threads, so exactly one block is resident and nothing overlaps the
register-staged global loads or the two
__syncthreadsper K step. The MMA layout ties I to 16 rowsper warp, so a 64-row tile at 128 threads is the only way to get two resident blocks (40.5 KB per
block at J = 128, two fit in the 100 KB per SM of GB10). Same kernels, same numerics, more resident
warps.
mul_mat_iddoes not want this. The MoE launch picks J fromncols_max= the total token countwhile each expert only holds
ncols_max*n_expert_used/n_expertscolumns, so halving the tile heightonly doubles the number of row tiles that pay for a mostly empty J tile: measured at -12 to -21 % on
MUL_MAT_IDat 2048 tokens. The table therefore keeps the stock 128-row tile in itsfallback == trueentries andmul_mat_idasks for that configuration on Blackwell. Requesting thebounds-checked variant cannot change results, it only enables the
src0->ne[1]range check in thetile loads and the write-back.
2. The FP4 accumulator is copied.
mma.sync...m16n8k64block-scaled already takes C as anaccumulator input, but
ggml_cuda_mmq_vec_dot_fp4_fp4_mmazeroed a temporary tile per instructionand added it into
sum[]afterwards. Accumulating intosum[]directly removes 805 FADD and all ofthe register spilling (26 STL / 30 LDL) from the NVFP4 J = 128 kernel; 224 -> 256 OMMA visible in the
loop.
The second commit fixes a latent bug found on the way:
mul_mat_q_casere-derivedfallbackfromargs.nrows_x % 128and ignored the flagggml_cuda_mul_mat_qhad already computed and already usedto size the
src1padding throughggml_cuda_mmq_get_J_max. The two agreed by construction whilethe only input was
ne01, but any other reason to want the bounds-checked configuration was silentlydropped. The decision is now carried in
mmq_args, so the configuration the padding was sized for isthe configuration that runs.
Measurements
DGX Spark GB10, sm_121a,
nvidia-smi -lgc 300,2100, every cell at 2086 to 2093 MHz, single node withthe GPU lock held, no other compute process resident.
test-backend-ops perf -b CUDA0, each cellbracketed by a
basecell before and after; the two base cells agree to within 2 % on every densecase and to 0.1 % end to end.
Dense
MUL_MAT, TFLOP/s (N x M x K, M = tokens)Batch 32 and q8_0 are unchanged: q8_0, q4_0, q5_0, iq4_xs and iq4_nl are deliberately left on the
stock tile, since the narrow tile was worth under 2 % for them at large N and cost 23 % at N = 512.
The types that gain are the ones whose mainloop is rescale bound (the K-quants) or FP4.
MoE
MUL_MAT_ID, TFLOP/s (256 experts, 8 used)Two base cells are shown because the first MoE cell of a window is cold; the patched cell sits inside
the base bracket on every case, i.e. no regression.
Without the
mul_mat_idguard the same build gives q4_K 1024 x 2048 x 2048 at 13.64 (-18.6 % againsta 16.75 base) and q8_0 at 9.80 (-20.9 %), which is what the guard is there to avoid.
End to end
llama-batched-bench -m Qwen3.8-27B-UD-Q4_K_XL.gguf -c 4608 -b 2048 -ub 2048 -ngl 99 -fa on -npp 512,2048 -ntg 16 -npl 1, base / patched / base in one lock window at one clock state.Things that were tried and did not work
cp.asyncfor the y tile. Both K halves fetched withcp.async.cginto a double buffer, onecp.async.wait_allplus one__syncthreadsreplacing the two register copy loops and one of thetwo barriers per K step. 21 to 24 % slower on every dense shape (q4_K 62.4 -> 48.6 TFLOP/s at
M = 512). The extra y tile in shared memory costs more occupancy than the asynchronous copy buys.
stack on q4_K J = 128); the build faults at runtime on
MUL_MAT_ID.dA_ik * dB_jkis a different scalar for every (i, j, k-block) and no partial sum can stay inint32 across k-blocks. Accumulating in int32 across a 256-wide superblock, which is what
CUTLASS-style block-scaled kernels do, requires quantizing the activations with one scale per 256
values instead of per 32. That changes results and needs a new
quantize_mmqlayout; it is notpart of this PR.
Proof that nothing else is touched
ggml/src/ggml-cuda/: the Blackwell tile table, the FP4 vec_dot,and the two lines of
mmq.cu/mmq.cuhthat carry the tile choice. No RPC, backend-registry,CPU, Metal, Vulkan, HIP or SYCL file is touched.
ggml_cuda_mmq_get_config_blackwell, which is onlyreached under
blackwell_mma_available(cc)on the host andBLACKWELL_MMA_AVAILABLEin devicecode. The
mul_mat_idterm infallbackis gated onblackwell_mma_available(cc)as well. Everyother GPU selects exactly the configuration it selected before.
ggml_cuda_mmq_vec_dot_fp4_fp4_mma, which only exists for theblock-scaled FP4 MMA path.
cmake -DGGML_CUDA=OFF -DGGML_RPC=ON -DGGML_NATIVE=ONbuilds clean with the patch applied(
test-backend-opsandllama-benchboth link).test-backend-ops test -b CUDA0on the patched build: 13572/13572 tests passed, Backend CUDA0:OK. Restricted runs:
-o MUL_MAT1193/1193,-o MUL_MAT_ID869/869.Still open
The CUTLASS W4A4 path of ggml-org#26704 reaches roughly 250 TFLOP/s on this class of GPU,
still about 1.75x what this gets to, so the tile table is not the end of the story. ggml-org#26977
(routed-MoE tile width from the mean columns per expert) and ggml-org#26159 (compact expert/tile job
list for the NVFP4 MoE grid) cherry-pick cleanly onto this and are a real MoE win here (+37 % q4_K
experts at 128 tokens, +39 % NVFP4 at 2048); they are upstream PRs, so they are not duplicated in
this one.