[common] [NVFP4] Per-token (row-scaled transpose) cast + CUTLASS GEMM kernels (1/3, split from #3045)#3206
[common] [NVFP4] Per-token (row-scaled transpose) cast + CUTLASS GEMM kernels (1/3, split from #3045)#3206cael-ling wants to merge 1 commit into
Conversation
Greptile SummaryThis PR adds the common C/CUDA layer for row-scaled NVFP4 transpose quantization and CUTLASS GEMM. The main changes are:
Confidence Score: 5/5This looks safe to merge.
Important Files Changed
Reviews (3): Last reviewed commit: "[common] Add NVFP4 row-scaled transpose ..." | Re-trigger Greptile |
| static void* persistent_buffer(size_t bytes, cudaStream_t stream, int which) { | ||
| static void* bufs[2] = {nullptr, nullptr}; | ||
| static size_t caps[2] = {0, 0}; | ||
| if (bytes > caps[which]) { | ||
| if (bufs[which] != nullptr) { | ||
| NVTE_CHECK_CUDA(cudaFreeAsync(bufs[which], stream)); | ||
| } | ||
| const size_t newcap = bytes + bytes / 2; // slack to avoid frequent regrows | ||
| NVTE_CHECK_CUDA(cudaMallocAsync(&bufs[which], newcap, stream)); | ||
| caps[which] = newcap; | ||
| } | ||
| return bufs[which]; |
There was a problem hiding this comment.
Shared Scratch Cross-Stream Race
When two grouped NVFP4 GEMMs run on different streams, both calls reuse the same static metadata and workspace buffers. One launch can overwrite or free the scratch while another stream's CUTLASS kernel is still reading it, which can produce wrong grouped GEMM outputs or allocator failures.
| cutlass::KernelHardwareInfo hw_info; | ||
| hw_info.device_id = 0; | ||
| hw_info.sm_count = cached_sm_count(); |
There was a problem hiding this comment.
Hardware Info Uses Device Zero
This launch always reports device 0 to CUTLASS, but the API runs on the caller's current CUDA device. On a multi-GPU job where the active device is not 0, the grouped scheduler can use the wrong SM count/device id and size persistent work incorrectly, causing poor scheduling or launch failures.
| cutlass::KernelHardwareInfo hw_info; | |
| hw_info.device_id = 0; | |
| hw_info.sm_count = cached_sm_count(); | |
| int device_id = 0; | |
| NVTE_CHECK_CUDA(cudaGetDevice(&device_id)); | |
| cutlass::KernelHardwareInfo hw_info; | |
| hw_info.device_id = device_id; | |
| hw_info.sm_count = cutlass::KernelHardwareInfo::query_device_multiprocessor_count(device_id); |
| NVTE_CHECK(a_t->data.dtype == DType::kFloat4E2M1 && b_t->data.dtype == DType::kFloat4E2M1, | ||
| "group ", g, ": A/B must be FP4 e2m1"); | ||
| NVTE_CHECK((d_t->data.dtype == DType::kFloat32) == d_is_fp32, "group ", g, | ||
| ": D dtype must be uniform across groups"); |
There was a problem hiding this comment.
The grouped API validates A/B/D and alpha tensors but never checks a_sf[g] or b_sf[g] before passing their bytes to CUTLASS as NVFP4 scale factors. A caller that passes an FP32 or otherwise wrong scale tensor gets silently corrupted GEMM results instead of the same clear error the dense APIs return.
| NVTE_CHECK(a_t->data.dtype == DType::kFloat4E2M1 && b_t->data.dtype == DType::kFloat4E2M1, | |
| "group ", g, ": A/B must be FP4 e2m1"); | |
| NVTE_CHECK((d_t->data.dtype == DType::kFloat32) == d_is_fp32, "group ", g, | |
| ": D dtype must be uniform across groups"); | |
| NVTE_CHECK(a_t->data.dtype == DType::kFloat4E2M1 && b_t->data.dtype == DType::kFloat4E2M1, | |
| "group ", g, ": A/B must be FP4 e2m1"); | |
| NVTE_CHECK(sa_t->data.dtype == DType::kFloat8E4M3 || sa_t->data.dtype == DType::kByte, | |
| "group ", g, ": A scale must be FP8 e4m3 (or raw uint8 byte)"); | |
| NVTE_CHECK(sb_t->data.dtype == DType::kFloat8E4M3 || sb_t->data.dtype == DType::kByte, | |
| "group ", g, ": B scale must be FP8 e4m3 (or raw uint8 byte)"); | |
| NVTE_CHECK((d_t->data.dtype == DType::kFloat32) == d_is_fp32, "group ", g, | |
| ": D dtype must be uniform across groups"); |
| const size_t *rng_state_ptr = nullptr; | ||
| if (quant_config_cpp.stochastic_rounding && quant_config_cpp.rng_state != nullptr) { | ||
| const Tensor *rng = convertNVTETensorCheck(quant_config_cpp.rng_state); | ||
| NVTE_CHECK(rng->dtype() == DType::kInt64, | ||
| "NVFP4 row-scaled transpose SR rng_state must be int64."); | ||
| rng_state_ptr = reinterpret_cast<const size_t *>(rng->data.dptr); | ||
| } |
There was a problem hiding this comment.
The stochastic-rounding path only verifies that rng_state is int64 before treating it as the seed/offset pair used by the kernel. If a caller passes a one-element int64 tensor with row-scaled transpose enabled, the kernel reads the missing offset past the allocation and can produce invalid randomness or an illegal memory access.
…quantize/GEMM APIs Introduce the NVFP4 row-scaled transpose quantization and CUTLASS GEMM kernels at the common (C/CUDA) layer, reached only through TransformerEngine's existing quantize/GEMM entry points (no new top-level C API), following how NVIDIA#2931 integrates row-scaled NVFP4: - Row-scaled transpose cast kernels (single + grouped): per-row (rowwise) and per-col (columnwise/transpose) NVFP4 scales in one pass. Selected inside nvte_quantize_v2 when a row-scaled NVFP4 output also allocates a columnwise buffer (output->row_scaled_nvfp4 && has_columnwise_data()); no dedicated config attribute. - CUTLASS NVFP4 row-scaled GEMM with per-row*per-col rescale fused into the EVT epilogue: routed from cublas_gemm() (nvte_cublas_gemm_v2) when both operands carry the row_scaled_nvfp4 flag, replacing the previous hard reject. The standalone post-scale and grouped GEMM kernels are internalized (no C API), to be wired into their dispatch paths by follow-up PRs. Naming follows the merged row-scaled NVFP4 convention (cutlass_nvfp4_*, row_scaled_transpose_*). C-only; the PyTorch recipe/quantizer/module wiring, and the backward/SR/RHT/2D-weight paths, land in follow-up PRs. Covered by C++ gtests for the row-scaled transpose cast and the row-scaled GEMM (GEMM suite GTEST_SKIPs on pre-Blackwell). Signed-off-by: Cael Ling <caell@nvidia.com>
e5f6f9b to
bec279d
Compare
Description
This is the first of three PRs splitting #3045 (NVFP4 per-token quantization recipe) into reviewable pieces. It lands only the common (C/CUDA) layer: the NVFP4 row-scaled transpose ("per-token") quantization kernels and CUTLASS GEMM.
Following how #2931 integrates row-scaled NVFP4, the kernels are reached only through TransformerEngine's existing
nvte_quantize_v2/nvte_cublas_gemm_v2entry points — no new top-level C-API. Selection is driven by tensor metadata (the existingrow_scaled_nvfp4flag from #2931) rather than a dedicated dispatch: a row-scaled NVFP4 tensor that also allocates a columnwise buffer is produced/consumed by the transpose kernels. Naming follows the merged row-scaled NVFP4 convention (cutlass_nvfp4_*,row_scaled_transpose_*) so the new kernels sit alongside the existing ones.What's included
Cast kernels
cast/nvfp4/quantize_transpose_row_scaled_nvfp4.{cu,h}— single-tensor row-scaled transpose cast: per-row (rowwise) and per-col (columnwise) NVFP4 scales produced in one pass.cast/nvfp4/group_quantize_transpose_row_scaled_nvfp4.{cu,h}— grouped (multi-tensor) variant.GEMM kernels
gemm/cutlass_nvfp4_gemm.{cu,h}+gemm/cutlass_nvfp4_grouped_gemm.cu— CUTLASS NVFP4 GEMM (dense + grouped) with per-row*per-col rescale fused into the EVT epilogue.gemm/cutlass_nvfp4_post_scale.cu— standalone post-scale kernel (internal helper).API integration (no new public C-API)
cast/dispatch/quantize.cuhselects the row-scaled transpose kernels insidenvte_quantize_v2when a row-scaled NVFP4 output also allocates a columnwise buffer (output->row_scaled_nvfp4 && has_columnwise_data()) — single + grouped. No dedicated config attribute.cublas_gemm()(nvte_cublas_gemm_v2) dispatches to the CUTLASS row-scaled kernel when both operands carry therow_scaled_nvfp4flag, replacing the previous hard reject. The dense entry point is the internalcutlass_nvfp4::gemm_row_scaled; the grouped GEMM and post-scale kernels are internalized (no C-API), to be wired into their dispatch paths by follow-up PRs.QuantizationConfigattributes for the RHT / random-sign-mask options (transformer_engine.h/common.h/transformer_engine.cpp), plumbed throughnvte_quantize_v2(default-off in this PR).Tests (C++ gtest, no Python dependency)
tests/cpp/operator/test_cast_nvfp4_transpose.cuwith a row-scaled-transpose suite, cross-validated against the merged row-scaled 1D kernel (Implement row-scaled NVFP4 fprop recipe #2931,RowScaled1D): rowwise vsRowScaled1D(input), columnwise vsRowScaled1D(input^T). Amaxes and FP8 block scales match bitwise; FP4 codes may differ only at rounding-midpoint ties (< 0.1%).tests/cpp/operator/test_cutlass_nvfp4_gemm.cuvalidating the row-scaled GEMM through the publicnvte_cublas_gemm_v2path (row-scaled NVFP4 operands with per-row amax vectors) against a dequant-matmul-rescale fp32 reference.What's NOT included (deferred)
To keep this PR focused on the kernels, the following from #3045 land in follow-up PRs:
Note: the SR / RHT / random-sign-mask code paths are present in the cast kernels and plumbed through the
nvte_quantize_v2config in this PR, but are exercised only in their default-off state. Their non-default behavior is validated together with the recipe that turns them on (follow-up PR), since SR is non-deterministic and RHT needs a dedicated reference (the #2931RowScaled1Dkernel used for cross-validation here has neither).Testing
C++ operator gtests on Blackwell (SM100), all passing:
CastNVFP4RowScaledTransposeTestSuiteCutlassNVFP4RowScaledGemmTestSuite(exercises thenvte_cublas_gemm_v2fold)Related
Type of change
Changes
Please list the changes introduced in this PR:
Checklist: